Merge pull request #90 from suzerain-io/easy_demo

Add <20 minutes Pinniped demo
This commit is contained in:
Andrew Keesler 2020-09-11 10:26:20 -04:00 committed by GitHub
commit b1d9665b03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1857 additions and 186 deletions

View File

@ -19,13 +19,16 @@ COPY tools ./tools
COPY hack ./hack COPY hack ./hack
# Build the executable binary (CGO_ENABLED=0 means static linking) # 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 # Use a runtime image based on Debian slim
FROM debian:10.5-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/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 # Document the port
EXPOSE 443 EXPOSE 443

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

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

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

View 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

View 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

View 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

View File

@ -1,5 +1,12 @@
# Deploying # 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 ## Tools
This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files. This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files.

1
go.mod
View File

@ -14,6 +14,7 @@ require (
github.com/stretchr/testify v1.6.1 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/apis v0.0.0-00010101000000-000000000000
github.com/suzerain-io/pinniped/generated/1.19/client 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/api v0.19.0
k8s.io/apimachinery v0.19.0 k8s.io/apimachinery v0.19.0
k8s.io/apiserver v0.19.0 k8s.io/apiserver v0.19.0

12
go.sum
View File

@ -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/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 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.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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 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.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-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 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= 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/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 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc=
github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= 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/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.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 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/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 h1:W3KBC2LFyfgd+wNudlfgCCsTo4q97MeNWrfz8/wSdSc=
github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c= 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/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 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 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-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 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.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/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 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY=
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= 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/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 h1:lhIKJ2nXLZZ+AfbHpYxTn0pXpNTTui0DX7DO3xeb1Zs=
github.com/sourcegraph/go-diff v0.5.3/go.mod h1:v9JDtjCE4HHHCZGId75rg8gkKKa98RVjBcBGsVmMmak= 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/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/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 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.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 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 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/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 h1:+lm6xFjVuNw/9t/Fh5sIwfNWefiD5bddzc6vwJ1TvRI=
github.com/ssgreg/nlreturn/v2 v2.0.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= 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/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.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/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/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 h1:Av96YVBwwNSe4MLR7iI/BIa3VyI7/djnto/pK3Uxbdo=
github.com/ultraware/funlen v0.0.2/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= 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/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 h1:If7Va4cM03mpgrNH9k49/VOicWpGoG70XPBFFODYDsg=
github.com/ultraware/whitespace v0.0.4/go.mod h1:aVMh/gQve5Maj9hQ/hg+F75lr/X5A89uZnzAmWSineA= 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-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 h1:yaM5S0KcY0lIoZo7Fl+oi91b/DdlU2zuWpfHrpWbCS0=
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 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/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-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-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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= 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.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 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-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 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.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= 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 h1:XyrFIJqTYZJ2DU7FBE/bSPz7b1HvbVBuBf07oeo6eTc=
k8s.io/api v0.19.0/go.mod h1:I1K45XlvTrDjmj5LoM5LuP/KYrhWbjUKT/SoPG0qTjw= k8s.io/api v0.19.0/go.mod h1:I1K45XlvTrDjmj5LoM5LuP/KYrhWbjUKT/SoPG0qTjw=

View 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'."

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

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

View File

@ -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) return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err)
} }
if notFound { 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 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, // 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 // since if we returned an error then the controller lib would just call us
// again and again, which would probably yield the same results. // 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 return nil
} }
certAge := time.Since(notBefore) certAge := time.Since(notBefore)
renewDelta := certAge - c.renewBefore 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) { if renewDelta >= 0 || time.Now().After(notAfter) {
err := c.k8sClient. err := c.k8sClient.
CoreV1(). CoreV1().

View File

@ -16,7 +16,6 @@ import (
corev1informers "k8s.io/client-go/informers/core/v1" corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/klog/v2" "k8s.io/klog/v2"
aggregatorclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
"github.com/suzerain-io/pinniped/internal/certauthority" "github.com/suzerain-io/pinniped/internal/certauthority"
pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller" pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller"
@ -32,34 +31,37 @@ const (
) )
type certsManagerController struct { type certsManagerController struct {
namespace string namespace string
k8sClient kubernetes.Interface k8sClient kubernetes.Interface
aggregatorClient aggregatorclient.Interface secretInformer corev1informers.SecretInformer
secretInformer corev1informers.SecretInformer
// certDuration is the lifetime of both the serving certificate and its CA // certDuration is the lifetime of both the serving certificate and its CA
// certificate that this controller will use when issuing the certificates. // certificate that this controller will use when issuing the certificates.
certDuration time.Duration certDuration time.Duration
generatedCACommonName string
serviceNameForGeneratedCertCommonName string
} }
func NewCertsManagerController( func NewCertsManagerController(namespace string,
namespace string,
k8sClient kubernetes.Interface, k8sClient kubernetes.Interface,
aggregatorClient aggregatorclient.Interface,
secretInformer corev1informers.SecretInformer, secretInformer corev1informers.SecretInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc, withInformer pinnipedcontroller.WithInformerOptionFunc,
withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc, withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc,
certDuration time.Duration, certDuration time.Duration,
generatedCACommonName string,
serviceNameForGeneratedCertCommonName string,
) controllerlib.Controller { ) controllerlib.Controller {
return controllerlib.New( return controllerlib.New(
controllerlib.Config{ controllerlib.Config{
Name: "certs-manager-controller", Name: "certs-manager-controller",
Syncer: &certsManagerController{ Syncer: &certsManagerController{
namespace: namespace, namespace: namespace,
k8sClient: k8sClient, k8sClient: k8sClient,
aggregatorClient: aggregatorClient, secretInformer: secretInformer,
secretInformer: secretInformer, certDuration: certDuration,
certDuration: certDuration, generatedCACommonName: generatedCACommonName,
serviceNameForGeneratedCertCommonName: serviceNameForGeneratedCertCommonName,
}, },
}, },
withInformer( withInformer(
@ -88,16 +90,13 @@ func (c *certsManagerController) Sync(ctx controllerlib.Context) error {
} }
// Create a CA. // 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 { if err != nil {
return fmt.Errorf("could not initialize CA: %w", err) 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. // 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( aggregatedAPIServerTLSCert, err := aggregatedAPIServerCA.Issue(
pkix.Name{CommonName: serviceEndpoint}, pkix.Name{CommonName: serviceEndpoint},
[]string{serviceEndpoint}, []string{serviceEndpoint},
@ -129,11 +128,6 @@ func (c *certsManagerController) Sync(ctx controllerlib.Context) error {
return fmt.Errorf("could not create secret: %w", err) return fmt.Errorf("could not create secret: %w", err)
} }
// Update the APIService to give it the new CA bundle. klog.Info("certsManagerController Sync successfully created secret")
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")
return nil return nil
} }

View File

@ -21,10 +21,7 @@ import (
kubeinformers "k8s.io/client-go/informers" kubeinformers "k8s.io/client-go/informers"
kubernetesfake "k8s.io/client-go/kubernetes/fake" kubernetesfake "k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing" 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/controllerlib"
"github.com/suzerain-io/pinniped/internal/testutil" "github.com/suzerain-io/pinniped/internal/testutil"
) )
@ -43,15 +40,7 @@ func TestManagerControllerOptions(t *testing.T) {
observableWithInformerOption = testutil.NewObservableWithInformerOption() observableWithInformerOption = testutil.NewObservableWithInformerOption()
observableWithInitialEventOption = testutil.NewObservableWithInitialEventOption() observableWithInitialEventOption = testutil.NewObservableWithInitialEventOption()
secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets() secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets()
_ = NewCertsManagerController( _ = NewCertsManagerController(installedInNamespace, nil, secretsInformer, observableWithInformerOption.WithInformer, observableWithInitialEventOption.WithInitialEvent, 0, "Pinniped CA", "pinniped-api")
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
)
secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer) secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer)
}) })
@ -123,7 +112,6 @@ func TestManagerControllerSync(t *testing.T) {
var subject controllerlib.Controller var subject controllerlib.Controller
var kubeAPIClient *kubernetesfake.Clientset var kubeAPIClient *kubernetesfake.Clientset
var aggregatorAPIClient *aggregatorfake.Clientset
var kubeInformerClient *kubernetesfake.Clientset var kubeInformerClient *kubernetesfake.Clientset
var kubeInformers kubeinformers.SharedInformerFactory var kubeInformers kubeinformers.SharedInformerFactory
var timeoutContext context.Context var timeoutContext context.Context
@ -137,11 +125,12 @@ func TestManagerControllerSync(t *testing.T) {
subject = NewCertsManagerController( subject = NewCertsManagerController(
installedInNamespace, installedInNamespace,
kubeAPIClient, kubeAPIClient,
aggregatorAPIClient,
kubeInformers.Core().V1().Secrets(), kubeInformers.Core().V1().Secrets(),
controllerlib.WithInformer, controllerlib.WithInformer,
controllerlib.WithInitialEvent, controllerlib.WithInitialEvent,
certDuration, certDuration,
"Pinniped CA",
"pinniped-api",
) )
// Set this at the last second to support calling subject.Name(). // Set this at the last second to support calling subject.Name().
@ -167,7 +156,6 @@ func TestManagerControllerSync(t *testing.T) {
kubeInformerClient = kubernetesfake.NewSimpleClientset() kubeInformerClient = kubernetesfake.NewSimpleClientset()
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0) kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
kubeAPIClient = kubernetesfake.NewSimpleClientset() kubeAPIClient = kubernetesfake.NewSimpleClientset()
aggregatorAPIClient = aggregatorfake.NewSimpleClientset()
}) })
it.After(func() { it.After(func() {
@ -186,111 +174,35 @@ func TestManagerControllerSync(t *testing.T) {
r.NoError(err) r.NoError(err)
}) })
when("the APIService exists", func() { it("creates the api-serving-cert Secret", func() {
it.Before(func() { startInformersAndController()
apiService := &apiregistrationv1.APIService{ err := controllerlib.TestSync(t, subject, *syncContext)
ObjectMeta: metav1.ObjectMeta{ r.NoError(err)
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 and updates the APIService's ca bundle", func() { // Check all the relevant fields from the create Secret action
startInformersAndController() r.Len(kubeAPIClient.Actions(), 1)
err := controllerlib.TestSync(t, subject, *syncContext) actualAction := kubeAPIClient.Actions()[0].(coretesting.CreateActionImpl)
r.NoError(err) 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 // Validate the created CA's lifetime.
r.Len(kubeAPIClient.Actions(), 1) validCACert := testutil.ValidateCertificate(t, actualCACert, actualCACert)
actualAction := kubeAPIClient.Actions()[0].(coretesting.CreateActionImpl) validCACert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute)
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. // Validate the created cert using the CA, and also validate the cert's hostname
validCACert := testutil.ValidateCertificate(t, actualCACert, actualCACert) validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain)
validCACert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute) validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc")
validCert.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.RequireMatchesPrivateKey(actualPrivateKey)
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())
})
}) })
when("creating the Secret fails", func() { 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() startInformersAndController()
err := controllerlib.TestSync(t, subject, *syncContext) err := controllerlib.TestSync(t, subject, *syncContext)
r.EqualError(err, "could not create secret: create failed") 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) 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() startInformersAndController()
err := controllerlib.TestSync(t, subject, *syncContext) err := controllerlib.TestSync(t, subject, *syncContext)
r.NoError(err) r.NoError(err)
r.Empty(kubeAPIClient.Actions()) r.Empty(kubeAPIClient.Actions())
r.Empty(aggregatorAPIClient.Actions())
}) })
}) })
}, spec.Parallel(), spec.Report(report.Terminal{})) }, spec.Parallel(), spec.Report(report.Terminal{}))

View File

@ -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) return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err)
} }
if notFound { 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. // The secret does not exist yet or was deleted.
c.dynamicCertProvider.Set(nil, nil) c.dynamicCertProvider.Set(nil, nil)
return nil return nil

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0
package apicerts package apicerts
import ( import (
"bytes"
"context" "context"
"fmt" "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) 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. // Update just the field we care about.
fetchedAPIService.Spec.CABundle = aggregatedAPIServerCA fetchedAPIService.Spec.CABundle = aggregatedAPIServerCA

View File

@ -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", name: "error on update",
mocks: func(c *aggregatorv1fake.Clientset) { mocks: func(c *aggregatorv1fake.Clientset) {
_ = c.Tracker().Add(&apiregistrationv1.APIService{ _ = c.Tracker().Add(&apiregistrationv1.APIService{
ObjectMeta: metav1.ObjectMeta{Name: apiServiceName}, 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) { c.PrependReactor("update", "apiservices", func(_ kubetesting.Action) (bool, runtime.Object, error) {
return true, nil, fmt.Errorf("error on update") return true, nil, fmt.Errorf("error on update")
}) })
}, },
caInput: []byte("some-ca-bundle"),
wantErr: "could not update API service: error on update", wantErr: "could not update API service: error on update",
}, },
{ {
@ -143,6 +170,7 @@ func TestUpdateAPIService(t *testing.T) {
}}, }},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@ -47,6 +47,9 @@ func PrepareControllers(
kubePublicNamespaceK8sInformers, installationNamespaceK8sInformers, installationNamespacePinnipedInformers := kubePublicNamespaceK8sInformers, installationNamespaceK8sInformers, installationNamespacePinnipedInformers :=
createInformers(serverInstallationNamespace, k8sClient, pinnipedClient) 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. // Create controller manager.
controllerManager := controllerlib. controllerManager := controllerlib.
NewManager(). NewManager().
@ -65,11 +68,21 @@ func PrepareControllers(
apicerts.NewCertsManagerController( apicerts.NewCertsManagerController(
serverInstallationNamespace, serverInstallationNamespace,
k8sClient, k8sClient,
aggregatorClient,
installationNamespaceK8sInformers.Core().V1().Secrets(), installationNamespaceK8sInformers.Core().V1().Secrets(),
controllerlib.WithInformer, controllerlib.WithInformer,
controllerlib.WithInitialEvent, controllerlib.WithInitialEvent,
servingCertDuration, servingCertDuration,
"Pinniped CA",
serviceName,
),
singletonWorker,
).
WithController(
apicerts.NewAPIServiceUpdaterController(
serverInstallationNamespace,
aggregatorClient,
installationNamespaceK8sInformers.Core().V1().Secrets(),
controllerlib.WithInformer,
), ),
singletonWorker, singletonWorker,
). ).

View File

@ -57,7 +57,7 @@ var maskKey = func(s string) string { return strings.ReplaceAll(s, "TESTING KEY"
func TestClient(t *testing.T) { func TestClient(t *testing.T) {
library.SkipUnlessIntegration(t) library.SkipUnlessIntegration(t)
library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable) 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() 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. // Using the CA bundle and host from the current (admin) kubeconfig, do the token exchange.
clientConfig := library.NewClientConfig(t) 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.NoError(t, err)
require.NotNil(t, resp.Status.ExpirationTimestamp) require.NotNil(t, resp.Status.ExpirationTimestamp)
require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute)) require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute))

View File

@ -10,6 +10,7 @@ import (
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
@ -28,6 +29,10 @@ import (
func TestSuccessfulCredentialRequest(t *testing.T) { func TestSuccessfulCredentialRequest(t *testing.T) {
library.SkipUnlessIntegration(t) library.SkipUnlessIntegration(t)
library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable) 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)) response, err := makeRequest(t, validCredentialRequestSpecWithRealToken(t))
require.NoError(t, err) require.NoError(t, err)
@ -39,6 +44,8 @@ func TestSuccessfulCredentialRequest(t *testing.T) {
require.NotNil(t, response.Status.Credential) require.NotNil(t, response.Status.Credential)
require.Empty(t, response.Status.Credential.Token) require.Empty(t, response.Status.Credential.Token)
require.NotEmpty(t, response.Status.Credential.ClientCertificateData) 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.NotEmpty(t, response.Status.Credential.ClientKeyData)
require.NotNil(t, response.Status.Credential.ExpirationTimestamp) require.NotNil(t, response.Status.Credential.ExpirationTimestamp)
require.InDelta(t, time.Until(response.Status.Credential.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute)) 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{{ Subjects: []rbacv1.Subject{{
Kind: rbacv1.UserKind, Kind: rbacv1.UserKind,
APIGroup: rbacv1.GroupName, APIGroup: rbacv1.GroupName,
Name: getCommonName(t, response.Status.Credential.ClientCertificateData), Name: testUsername,
}}, }},
RoleRef: rbacv1.RoleRef{ RoleRef: rbacv1.RoleRef{
Kind: "ClusterRole", Kind: "ClusterRole",
@ -85,34 +92,37 @@ func TestSuccessfulCredentialRequest(t *testing.T) {
require.NotEmpty(t, listNamespaceResponse.Items) require.NotEmpty(t, listNamespaceResponse.Items)
}) })
t.Run("access as group", func(t *testing.T) { for _, group := range expectedTestUserGroups {
addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ group := group
TypeMeta: metav1.TypeMeta{}, t.Run("access as group "+group, func(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{ addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{
Name: "integration-test-group-readonly-role-binding", TypeMeta: metav1.TypeMeta{},
}, ObjectMeta: metav1.ObjectMeta{
Subjects: []rbacv1.Subject{{ Name: "integration-test-group-readonly-role-binding",
Kind: rbacv1.GroupKind, },
APIGroup: rbacv1.GroupName, Subjects: []rbacv1.Subject{{
Name: "tmc:member", Kind: rbacv1.GroupKind,
}}, APIGroup: rbacv1.GroupName,
RoleRef: rbacv1.RoleRef{ Name: group,
Kind: "ClusterRole", }},
APIGroup: rbacv1.GroupName, RoleRef: rbacv1.RoleRef{
Name: "view", Kind: "ClusterRole",
}, APIGroup: rbacv1.GroupName,
}) Name: "view",
},
})
// Use the client which is authenticated as the TMC group to list namespaces // Use the client which is authenticated as the TMC group to list namespaces
var listNamespaceResponse *v1.NamespaceList var listNamespaceResponse *v1.NamespaceList
var canListNamespaces = func() bool { var canListNamespaces = func() bool {
listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
return err == nil return err == nil
} }
assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) 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.NoError(t, err) // prints out the error and stops the test in case of failure
require.NotEmpty(t, listNamespaceResponse.Items) require.NotEmpty(t, listNamespaceResponse.Items)
}) })
}
} }
func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser(t *testing.T) { 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 { 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{ return v1alpha1.CredentialRequestSpec{
Type: v1alpha1.TokenCredentialType, 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 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
}