Use duration and renewBefore to control API cert rotation

These configuration knobs are much more human-understandable than the
previous percentage-based threshold flag.

We now allow users to set the lifetime of the serving cert via a ConfigMap.
Previously this was hardcoded to 1 year.

Signed-off-by: Andrew Keesler <akeesler@vmware.com>
This commit is contained in:
Andrew Keesler 2020-08-20 15:17:18 -04:00
parent 3929fa672e
commit 39c299a32d
No known key found for this signature in database
GPG Key ID: 27CE0444346F9413
14 changed files with 190 additions and 136 deletions

View File

@ -28,24 +28,20 @@ type certsExpirerController struct {
k8sClient kubernetes.Interface k8sClient kubernetes.Interface
secretInformer corev1informers.SecretInformer secretInformer corev1informers.SecretInformer
// ageThreshold is a percentage (i.e., a real number between 0 and 1, // renewBefore is the amount of time after the cert's issuance where
// inclusive) indicating the point in a certificate's lifetime where this // this controller will start to try to rotate it.
// controller will start to try to rotate it. renewBefore time.Duration
//
// Said another way, once ageThreshold % of a certificate's lifetime has
// passed, this controller will try to delete it to force a new certificate
// to be created.
ageThreshold float32
} }
// NewCertsExpirerController returns a controller.Controller that will delete a // NewCertsExpirerController returns a controller.Controller that will delete a
// CA once it gets within some threshold of its expiration time. // certificate secret once it gets within some threshold of its expiration time. The
// deletion forces rotation of the secret with the help of other controllers.
func NewCertsExpirerController( func NewCertsExpirerController(
namespace string, namespace string,
k8sClient kubernetes.Interface, k8sClient kubernetes.Interface,
secretInformer corev1informers.SecretInformer, secretInformer corev1informers.SecretInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc, withInformer pinnipedcontroller.WithInformerOptionFunc,
ageThreshold float32, renewBefore time.Duration,
) controller.Controller { ) controller.Controller {
return controller.New( return controller.New(
controller.Config{ controller.Config{
@ -54,7 +50,7 @@ func NewCertsExpirerController(
namespace: namespace, namespace: namespace,
k8sClient: k8sClient, k8sClient: k8sClient,
secretInformer: secretInformer, secretInformer: secretInformer,
ageThreshold: ageThreshold, renewBefore: renewBefore,
}, },
}, },
withInformer( withInformer(
@ -77,20 +73,19 @@ func (c *certsExpirerController) Sync(ctx controller.Context) error {
return nil return nil
} }
notBefore, notAfter, err := getCABounds(secret) notBefore, notAfter, err := getCertBounds(secret)
if err != nil { if err != nil {
// If we can't get the CA, then really all we can do is log something, since // If we can't read the cert, then really all we can do is log something,
// if we returned an error then the controller lib would just call us again // since if we returned an error then the controller lib would just call us
// 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
} }
caLifetime := notAfter.Sub(notBefore) certAge := time.Since(notBefore)
caAge := time.Since(notBefore) renewDelta := certAge - c.renewBefore
thresholdDelta := (float32(caAge) / float32(caLifetime)) - c.ageThreshold klog.Infof("certsExpirerController Sync() found a renew delta of %s", renewDelta)
klog.Infof("certsExpirerController Sync() found a CA age threshold delta of %.9g", thresholdDelta) if renewDelta >= 0 || time.Now().After(notAfter) {
if thresholdDelta > 0 {
err := c.k8sClient. err := c.k8sClient.
CoreV1(). CoreV1().
Secrets(c.namespace). Secrets(c.namespace).
@ -105,24 +100,25 @@ func (c *certsExpirerController) Sync(ctx controller.Context) error {
return nil return nil
} }
// getCABounds returns the NotBefore and NotAfter fields of the CA certificate // getCertBounds returns the NotBefore and NotAfter fields of the TLS
// in the provided secret, or an error. Not that it expects the provided secret // certificate in the provided secret, or an error. Not that it expects the
// to contain the well-known data keys from this package (see certs_manager.go). // provided secret to contain the well-known data keys from this package (see
func getCABounds(secret *corev1.Secret) (time.Time, time.Time, error) { // certs_manager.go).
caPEM := secret.Data[caCertificateSecretKey] func getCertBounds(secret *corev1.Secret) (time.Time, time.Time, error) {
if caPEM == nil { certPEM := secret.Data[tlsCertificateChainSecretKey]
return time.Time{}, time.Time{}, constable.Error("failed to find CA") if certPEM == nil {
return time.Time{}, time.Time{}, constable.Error("failed to find certificate")
} }
caBlock, _ := pem.Decode(caPEM) certBlock, _ := pem.Decode(certPEM)
if caBlock == nil { if certBlock == nil {
return time.Time{}, time.Time{}, constable.Error("failed to decode CA PEM") return time.Time{}, time.Time{}, constable.Error("failed to decode certificate PEM")
} }
caCrt, err := x509.ParseCertificate(caBlock.Bytes) cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil { if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse CA: %w", err) return time.Time{}, time.Time{}, fmt.Errorf("failed to parse certificate: %w", err)
} }
return caCrt.NotBefore, caCrt.NotAfter, nil return cert.NotBefore, cert.NotAfter, nil
} }

View File

@ -96,7 +96,7 @@ func TestExpirerControllerFilters(t *testing.T) {
nil, // k8sClient, not needed nil, // k8sClient, not needed
secretsInformer, secretsInformer,
withInformer.WithInformer, withInformer.WithInformer,
0, // ageThreshold, not needed 0, // renewBefore, not needed
) )
unrelated := corev1.Secret{} unrelated := corev1.Secret{}
@ -114,7 +114,7 @@ func TestExpirerControllerSync(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
ageThreshold float32 renewBefore time.Duration
fillSecretData func(*testing.T, map[string][]byte) fillSecretData func(*testing.T, map[string][]byte)
configKubeAPIClient func(*kubernetesfake.Clientset) configKubeAPIClient func(*kubernetesfake.Clientset)
wantDelete bool wantDelete bool
@ -131,46 +131,61 @@ func TestExpirerControllerSync(t *testing.T) {
}, },
{ {
name: "lifetime below threshold", name: "lifetime below threshold",
ageThreshold: 0.7, renewBefore: 7 * time.Hour,
fillSecretData: func(t *testing.T, m map[string][]byte) { fillSecretData: func(t *testing.T, m map[string][]byte) {
caPEM, err := testutil.CreateCertificate( certPEM, err := testutil.CreateCertificate(
time.Now().Add(-5*time.Hour), time.Now().Add(-5*time.Hour),
time.Now().Add(5*time.Hour), time.Now().Add(5*time.Hour),
) )
require.NoError(t, err) require.NoError(t, err)
// See cert_manager.go for this constant. // See certs_manager.go for this constant.
m["caCertificate"] = caPEM m["tlsCertificateChain"] = certPEM
}, },
wantDelete: false, wantDelete: false,
}, },
{ {
name: "lifetime above threshold", name: "lifetime above threshold",
ageThreshold: 0.3, renewBefore: 3 * time.Hour,
fillSecretData: func(t *testing.T, m map[string][]byte) { fillSecretData: func(t *testing.T, m map[string][]byte) {
caPEM, err := testutil.CreateCertificate( certPEM, err := testutil.CreateCertificate(
time.Now().Add(-5*time.Hour), time.Now().Add(-5*time.Hour),
time.Now().Add(5*time.Hour), time.Now().Add(5*time.Hour),
) )
require.NoError(t, err) require.NoError(t, err)
// See cert_manager.go for this constant. // See certs_manager.go for this constant.
m["caCertificate"] = caPEM m["tlsCertificateChain"] = certPEM
},
wantDelete: true,
},
{
name: "cert expired",
renewBefore: 3 * time.Hour,
fillSecretData: func(t *testing.T, m map[string][]byte) {
certPEM, err := testutil.CreateCertificate(
time.Now().Add(-2*time.Hour),
time.Now().Add(-1*time.Hour),
)
require.NoError(t, err)
// See certs_manager.go for this constant.
m["tlsCertificateChain"] = certPEM
}, },
wantDelete: true, wantDelete: true,
}, },
{ {
name: "delete failure", name: "delete failure",
ageThreshold: 0.3, renewBefore: 3 * time.Hour,
fillSecretData: func(t *testing.T, m map[string][]byte) { fillSecretData: func(t *testing.T, m map[string][]byte) {
caPEM, err := testutil.CreateCertificate( certPEM, err := testutil.CreateCertificate(
time.Now().Add(-5*time.Hour), time.Now().Add(-5*time.Hour),
time.Now().Add(5*time.Hour), time.Now().Add(5*time.Hour),
) )
require.NoError(t, err) require.NoError(t, err)
// See cert_manager.go for this constant. // See certs_manager.go for this constant.
m["caCertificate"] = caPEM m["tlsCertificateChain"] = certPEM
}, },
configKubeAPIClient: func(c *kubernetesfake.Clientset) { configKubeAPIClient: func(c *kubernetesfake.Clientset) {
c.PrependReactor("delete", "secrets", func(_ kubetesting.Action) (bool, runtime.Object, error) { c.PrependReactor("delete", "secrets", func(_ kubetesting.Action) (bool, runtime.Object, error) {
@ -185,8 +200,8 @@ func TestExpirerControllerSync(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048) privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err) require.NoError(t, err)
// See cert_manager.go for this constant. // See certs_manager.go for this constant.
m["caCertificate"] = x509.MarshalPKCS1PrivateKey(privateKey) m["tlsCertificateChain"] = x509.MarshalPKCS1PrivateKey(privateKey)
}, },
wantDelete: false, wantDelete: false,
}, },
@ -205,7 +220,7 @@ func TestExpirerControllerSync(t *testing.T) {
} }
kubeInformerClient := kubernetesfake.NewSimpleClientset() kubeInformerClient := kubernetesfake.NewSimpleClientset()
name := "api-serving-cert" // See cert_manager.go. name := "api-serving-cert" // See certs_manager.go.
namespace := "some-namespace" namespace := "some-namespace"
if test.fillSecretData != nil { if test.fillSecretData != nil {
secret := &corev1.Secret{ secret := &corev1.Secret{
@ -231,7 +246,7 @@ func TestExpirerControllerSync(t *testing.T) {
kubeAPIClient, kubeAPIClient,
kubeInformers.Core().V1().Secrets(), kubeInformers.Core().V1().Secrets(),
controller.WithInformer, controller.WithInformer,
test.ageThreshold, test.renewBefore,
) )
// Must start informers before calling TestRunSynchronously(). // Must start informers before calling TestRunSynchronously().

View File

@ -36,6 +36,10 @@ type certsManagerController struct {
k8sClient kubernetes.Interface k8sClient kubernetes.Interface
aggregatorClient aggregatorclient.Interface aggregatorClient aggregatorclient.Interface
secretInformer corev1informers.SecretInformer secretInformer corev1informers.SecretInformer
// certDuration is the lifetime of the serving certificate that this
// controller will use when issuing the serving certificate.
certDuration time.Duration
} }
func NewCertsManagerController( func NewCertsManagerController(
@ -45,6 +49,7 @@ func NewCertsManagerController(
secretInformer corev1informers.SecretInformer, secretInformer corev1informers.SecretInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc, withInformer pinnipedcontroller.WithInformerOptionFunc,
withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc, withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc,
certDuration time.Duration,
) controller.Controller { ) controller.Controller {
return controller.New( return controller.New(
controller.Config{ controller.Config{
@ -54,6 +59,7 @@ func NewCertsManagerController(
k8sClient: k8sClient, k8sClient: k8sClient,
aggregatorClient: aggregatorClient, aggregatorClient: aggregatorClient,
secretInformer: secretInformer, secretInformer: secretInformer,
certDuration: certDuration,
}, },
}, },
withInformer( withInformer(
@ -95,7 +101,7 @@ func (c *certsManagerController) Sync(ctx controller.Context) error {
aggregatedAPIServerTLSCert, err := aggregatedAPIServerCA.Issue( aggregatedAPIServerTLSCert, err := aggregatedAPIServerCA.Issue(
pkix.Name{CommonName: serviceEndpoint}, pkix.Name{CommonName: serviceEndpoint},
[]string{serviceEndpoint}, []string{serviceEndpoint},
24*365*time.Hour, c.certDuration,
) )
if err != nil { if err != nil {
return fmt.Errorf("could not issue serving certificate: %w", err) return fmt.Errorf("could not issue serving certificate: %w", err)

View File

@ -50,6 +50,7 @@ func TestManagerControllerOptions(t *testing.T) {
secretsInformer, secretsInformer,
observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters 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 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)
}) })
@ -116,6 +117,7 @@ func TestManagerControllerOptions(t *testing.T) {
func TestManagerControllerSync(t *testing.T) { func TestManagerControllerSync(t *testing.T) {
spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) { spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) {
const installedInNamespace = "some-namespace" const installedInNamespace = "some-namespace"
const certDuration = 12345678 * time.Second
var r *require.Assertions var r *require.Assertions
@ -139,6 +141,7 @@ func TestManagerControllerSync(t *testing.T) {
kubeInformers.Core().V1().Secrets(), kubeInformers.Core().V1().Secrets(),
controller.WithInformer, controller.WithInformer,
controller.WithInitialEvent, controller.WithInitialEvent,
certDuration,
) )
// Set this at the last second to support calling subject.Name(). // Set this at the last second to support calling subject.Name().
@ -221,7 +224,7 @@ func TestManagerControllerSync(t *testing.T) {
// Validate the created cert using the CA, and also validate the cert's hostname // Validate the created cert using the CA, and also validate the cert's hostname
validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain) validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain)
validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc") validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc")
validCert.RequireLifetime(time.Now(), time.Now().Add(24*365*time.Hour), 2*time.Minute) validCert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 2*time.Minute)
validCert.RequireMatchesPrivateKey(actualPrivateKey) validCert.RequireMatchesPrivateKey(actualPrivateKey)
// Make sure we updated the APIService caBundle and left it otherwise unchanged // Make sure we updated the APIService caBundle and left it otherwise unchanged

View File

@ -34,7 +34,8 @@ func PrepareControllers(
serverInstallationNamespace string, serverInstallationNamespace string,
discoveryURLOverride *string, discoveryURLOverride *string,
dynamicCertProvider provider.DynamicTLSServingCertProvider, dynamicCertProvider provider.DynamicTLSServingCertProvider,
servingCertRotationThreshold float32, servingCertDuration time.Duration,
servingCertRenewBefore time.Duration,
) (func(ctx context.Context), error) { ) (func(ctx context.Context), error) {
// Create k8s clients. // Create k8s clients.
k8sClient, aggregatorClient, pinnipedClient, err := createClients() k8sClient, aggregatorClient, pinnipedClient, err := createClients()
@ -68,6 +69,7 @@ func PrepareControllers(
installationNamespaceK8sInformers.Core().V1().Secrets(), installationNamespaceK8sInformers.Core().V1().Secrets(),
controller.WithInformer, controller.WithInformer,
controller.WithInitialEvent, controller.WithInitialEvent,
servingCertDuration,
), ),
singletonWorker, singletonWorker,
). ).
@ -86,7 +88,7 @@ func PrepareControllers(
k8sClient, k8sClient,
installationNamespaceK8sInformers.Core().V1().Secrets(), installationNamespaceK8sInformers.Core().V1().Secrets(),
controller.WithInformer, controller.WithInformer,
servingCertRotationThreshold, servingCertRenewBefore,
), ),
singletonWorker, singletonWorker,
) )

View File

@ -10,11 +10,9 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strconv"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
genericoptions "k8s.io/apiserver/pkg/server/options" genericoptions "k8s.io/apiserver/pkg/server/options"
"k8s.io/apiserver/plugin/pkg/authenticator/token/webhook" "k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
@ -23,7 +21,6 @@ import (
"github.com/suzerain-io/pinniped/internal/apiserver" "github.com/suzerain-io/pinniped/internal/apiserver"
"github.com/suzerain-io/pinniped/internal/certauthority/kubecertauthority" "github.com/suzerain-io/pinniped/internal/certauthority/kubecertauthority"
"github.com/suzerain-io/pinniped/internal/constable"
"github.com/suzerain-io/pinniped/internal/controllermanager" "github.com/suzerain-io/pinniped/internal/controllermanager"
"github.com/suzerain-io/pinniped/internal/downward" "github.com/suzerain-io/pinniped/internal/downward"
"github.com/suzerain-io/pinniped/internal/provider" "github.com/suzerain-io/pinniped/internal/provider"
@ -32,31 +29,6 @@ import (
"github.com/suzerain-io/pinniped/pkg/config" "github.com/suzerain-io/pinniped/pkg/config"
) )
type percentageValue struct {
percentage float32
}
var _ pflag.Value = &percentageValue{}
func (p *percentageValue) String() string {
return fmt.Sprintf("%.2f%%", p.percentage*100)
}
func (p *percentageValue) Set(s string) error {
f, err := strconv.ParseFloat(s, 32)
if err != nil || f < 0 || f > 1 {
return constable.Error("must pass real number between 0 and 1")
}
p.percentage = float32(f)
return nil
}
func (p *percentageValue) Type() string {
return "percentage"
}
// App is an object that represents the pinniped-server application. // App is an object that represents the pinniped-server application.
type App struct { type App struct {
cmd *cobra.Command cmd *cobra.Command
@ -64,7 +36,6 @@ type App struct {
// CLI flags // CLI flags
configPath string configPath string
downwardAPIPath string downwardAPIPath string
servingCertRotationThreshold percentageValue
} }
// This is ignored for now because we turn off etcd storage below, but this is // This is ignored for now because we turn off etcd storage below, but this is
@ -118,13 +89,6 @@ func addCommandlineFlagsToCommand(cmd *cobra.Command, app *App) {
"/etc/podinfo", "/etc/podinfo",
"path to Downward API volume mount", "path to Downward API volume mount",
) )
app.servingCertRotationThreshold.percentage = .70 // default
cmd.Flags().Var(
&app.servingCertRotationThreshold,
"serving-cert-rotation-threshold",
"real number between 0 and 1 indicating percentage of lifetime before rotation of serving cert",
)
} }
// Boot the aggregated API server, which will in turn boot the controllers. // Boot the aggregated API server, which will in turn boot the controllers.
@ -168,7 +132,8 @@ func (a *App) runServer(ctx context.Context) error {
serverInstallationNamespace, serverInstallationNamespace,
cfg.DiscoveryInfo.URL, cfg.DiscoveryInfo.URL,
dynamicCertProvider, dynamicCertProvider,
a.servingCertRotationThreshold.percentage, time.Duration(*cfg.APIConfig.ServingCertificateConfig.DurationSeconds)*time.Second,
time.Duration(*cfg.APIConfig.ServingCertificateConfig.RenewBeforeSeconds)*time.Second,
) )
if err != nil { if err != nil {
return fmt.Errorf("could not prepare controllers: %w", err) return fmt.Errorf("could not prepare controllers: %w", err)

View File

@ -29,7 +29,6 @@ Flags:
--downward-api-path string path to Downward API volume mount (default "/etc/podinfo") --downward-api-path string path to Downward API volume mount (default "/etc/podinfo")
-h, --help help for pinniped-server -h, --help help for pinniped-server
--log-flush-frequency duration Maximum number of seconds between log flushes (default 5s) --log-flush-frequency duration Maximum number of seconds between log flushes (default 5s)
--serving-cert-rotation-threshold percentage real number between 0 and 1 indicating percentage of lifetime before rotation of serving cert (default 70.00%)
` `
func TestCommand(t *testing.T) { func TestCommand(t *testing.T) {
@ -69,30 +68,6 @@ func TestCommand(t *testing.T) {
}, },
wantErr: `unknown command "tuna" for "pinniped-server"`, wantErr: `unknown command "tuna" for "pinniped-server"`,
}, },
{
name: "PercentageIsNotRealNumber",
args: []string{
"--config", "some/path/to/config.yaml",
"--serving-cert-rotation-threshold", "tuna",
},
wantErr: `invalid argument "tuna" for "--serving-cert-rotation-threshold" flag: must pass real number between 0 and 1`,
},
{
name: "PercentageIsTooSmall",
args: []string{
"--config", "some/path/to/config.yaml",
"--serving-cert-rotation-threshold", "-1",
},
wantErr: `invalid argument "-1" for "--serving-cert-rotation-threshold" flag: must pass real number between 0 and 1`,
},
{
name: "PercentageIsTooLarge",
args: []string{
"--config", "some/path/to/config.yaml",
"--serving-cert-rotation-threshold", "75",
},
wantErr: `invalid argument "75" for "--serving-cert-rotation-threshold" flag: must pass real number between 0 and 1`,
},
} }
for _, test := range tests { for _, test := range tests {
test := test test := test

View File

@ -9,9 +9,10 @@ package api
type Config struct { type Config struct {
WebhookConfig WebhookConfigSpec `json:"webhook"` WebhookConfig WebhookConfigSpec `json:"webhook"`
DiscoveryInfo DiscoveryInfoSpec `json:"discovery"` DiscoveryInfo DiscoveryInfoSpec `json:"discovery"`
APIConfig APIConfigSpec `json:"api"`
} }
// WebhookConfig contains configuration knobs specific to Pinniped's use // WebhookConfig contains configuration knobs specific to pinniped's use
// of a webhook for token validation. // of a webhook for token validation.
type WebhookConfigSpec struct { type WebhookConfigSpec struct {
// URL contains the URL of the webhook that pinniped will use // URL contains the URL of the webhook that pinniped will use
@ -31,3 +32,26 @@ type DiscoveryInfoSpec struct {
// URL contains the URL at which pinniped can be contacted. // URL contains the URL at which pinniped can be contacted.
URL *string `json:"url,omitempty"` URL *string `json:"url,omitempty"`
} }
// APIConfigSpec contains configuration knobs for the pinniped API.
//nolint: golint
type APIConfigSpec struct {
ServingCertificateConfig ServingCertificateConfigSpec `json:"servingCertificate"`
}
// ServingCertificateConfigSpec contains the configuration knobs for the API's
// serving certificate, i.e., the x509 certificate that it uses for the server
// certificate in inbound TLS connections.
type ServingCertificateConfigSpec struct {
// DurationSeconds is the validity period, in seconds, of the API serving
// certificate. By default, the serving certificate is issued for 31536000
// seconds (1 year).
DurationSeconds *int64 `json:"durationSeconds,omitempty"`
// RenewBeforeSeconds is the period of time, in seconds, that pinniped will
// wait before rotating the serving certificate. This period of time starts
// upon issuance of the serving certificate. This must be less than
// DurationSeconds. By default, pinniped begins rotation after 23328000
// seconds (about 9 months).
RenewBeforeSeconds *int64 `json:"renewBeforeSeconds,omitempty"`
}

View File

@ -13,10 +13,18 @@ import (
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
"github.com/suzerain-io/pinniped/internal/constable"
"github.com/suzerain-io/pinniped/pkg/config/api" "github.com/suzerain-io/pinniped/pkg/config/api"
) )
// FromPath loads an api.Config from a provided local file path. const (
aboutAYear = 60 * 60 * 24 * 365
about9Months = 60 * 60 * 24 * 30 * 9
)
// FromPath loads an api.Config from a provided local file path, inserts any
// defaults (from the api.Config documentation), and verifies that the config is
// valid (per the api.Config documentation).
// //
// Note! The api.Config file should contain base64-encoded WebhookCABundle data. // Note! The api.Config file should contain base64-encoded WebhookCABundle data.
// This function will decode that base64-encoded data to PEM bytes to be stored // This function will decode that base64-encoded data to PEM bytes to be stored
@ -32,5 +40,33 @@ func FromPath(path string) (*api.Config, error) {
return nil, fmt.Errorf("decode yaml: %w", err) return nil, fmt.Errorf("decode yaml: %w", err)
} }
maybeSetAPIDefaults(&config.APIConfig)
if err := validateAPI(&config.APIConfig); err != nil {
return nil, fmt.Errorf("validate api: %w", err)
}
return &config, nil return &config, nil
} }
func maybeSetAPIDefaults(apiConfig *api.APIConfigSpec) {
if apiConfig.ServingCertificateConfig.DurationSeconds == nil {
apiConfig.ServingCertificateConfig.DurationSeconds = int64Ptr(aboutAYear)
}
if apiConfig.ServingCertificateConfig.RenewBeforeSeconds == nil {
apiConfig.ServingCertificateConfig.RenewBeforeSeconds = int64Ptr(about9Months)
}
}
func validateAPI(apiConfig *api.APIConfigSpec) error {
if *apiConfig.ServingCertificateConfig.DurationSeconds < *apiConfig.ServingCertificateConfig.RenewBeforeSeconds {
return constable.Error("durationSeconds cannot be smaller than renewBeforeSeconds")
}
return nil
}
func int64Ptr(i int64) *int64 {
return &i
}

View File

@ -18,6 +18,7 @@ func TestFromPath(t *testing.T) {
name string name string
path string path string
wantConfig *api.Config wantConfig *api.Config
wantError string
}{ }{
{ {
name: "Happy", name: "Happy",
@ -30,11 +31,17 @@ func TestFromPath(t *testing.T) {
URL: "https://tuna.com/fish?marlin", URL: "https://tuna.com/fish?marlin",
CABundle: []byte("-----BEGIN CERTIFICATE-----..."), CABundle: []byte("-----BEGIN CERTIFICATE-----..."),
}, },
APIConfig: api.APIConfigSpec{
ServingCertificateConfig: api.ServingCertificateConfigSpec{
DurationSeconds: int64Ptr(3600),
RenewBeforeSeconds: int64Ptr(2400),
},
},
}, },
}, },
{ {
name: "NoDiscovery", name: "Default",
path: "testdata/no-discovery.yaml", path: "testdata/default.yaml",
wantConfig: &api.Config{ wantConfig: &api.Config{
DiscoveryInfo: api.DiscoveryInfoSpec{ DiscoveryInfo: api.DiscoveryInfoSpec{
URL: nil, URL: nil,
@ -43,21 +50,34 @@ func TestFromPath(t *testing.T) {
URL: "https://tuna.com/fish?marlin", URL: "https://tuna.com/fish?marlin",
CABundle: []byte("-----BEGIN CERTIFICATE-----..."), CABundle: []byte("-----BEGIN CERTIFICATE-----..."),
}, },
APIConfig: api.APIConfigSpec{
ServingCertificateConfig: api.ServingCertificateConfigSpec{
DurationSeconds: int64Ptr(60 * 60 * 24 * 365), // about a year
RenewBeforeSeconds: int64Ptr(60 * 60 * 24 * 30 * 9), // about 9 months
}, },
}, },
},
},
{
name: "InvalidDurationRenewBefore",
path: "testdata/invalid-duration-renew-before.yaml",
wantError: "validate api: durationSeconds cannot be smaller than renewBeforeSeconds",
},
} }
for _, test := range tests { for _, test := range tests {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
config, err := FromPath(test.path) config, err := FromPath(test.path)
if test.wantError != "" {
require.EqualError(t, err, test.wantError)
} else {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, test.wantConfig, config) require.Equal(t, test.wantConfig, config)
}
}) })
} }
} }
func stringPtr(s string) *string { func stringPtr(s string) *string {
sPtr := new(string) return &s
*sPtr = s
return sPtr
} }

View File

@ -4,3 +4,7 @@ discovery:
webhook: webhook:
url: https://tuna.com/fish?marlin url: https://tuna.com/fish?marlin
caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tLi4u caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tLi4u
api:
servingCertificate:
durationSeconds: 3600
renewBeforeSeconds: 2400

View File

@ -0,0 +1,8 @@
---
webhook:
url: https://tuna.com/fish?marlin
caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tLi4u
api:
servingCertificate:
durationSeconds: 2400
renewBeforeSeconds: 3600

View File

@ -58,7 +58,7 @@ func TestAPIServingCertificateAutoCreationAndRotation(t *testing.T) {
return err return err
} }
secret.Data["caCertificate"], err = createExpiredCertificate() secret.Data["tlsCertificateChain"], err = createExpiredCertificate()
if err != nil { if err != nil {
return err return err
} }