diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
index b06f4105..8964427e 100644
--- a/.github/dependabot.yaml
+++ b/.github/dependabot.yaml
@@ -11,6 +11,14 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ groups:
+ gomod-breaking:
+ update-types:
+ - major
+ gomod-backward-compatible:
+ update-types:
+ - minor
+ - patch
- package-ecosystem: "github-actions"
directory: "/"
schedule:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 3b4edea2..ff2e44bf 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -143,8 +143,9 @@ jobs:
make ci-build BUILD_DIR="${BUILD_DIR}"
OUT_DIR="${BUILD_DIR}/out"
mkdir -p "${OUT_DIR}"
+ cp -a LICENSE "${BUILD_DIR}/LICENSE.txt"
ZIP_FILE="${OUT_DIR}/${{ env.PKG_NAME }}_${{ needs.get-product-version.outputs.product-version }}_linux_${{ matrix.arch }}.zip"
- zip -r -j "${ZIP_FILE}" dist/${{ env.GOOS }}/${{ env.GOARCH }}/${{ env.PKG_NAME }} LICENSE
+ zip -r -j "${ZIP_FILE}" dist/${{ env.GOOS }}/${{ env.GOARCH }}/${{ env.PKG_NAME }} ${BUILD_DIR}/LICENSE.txt
echo "path=${ZIP_FILE}" >> $GITHUB_OUTPUT
echo "name=$(basename ${ZIP_FILE})" >> $GITHUB_OUTPUT
- name: Upload binary
diff --git a/api/v1beta1/hcpvaultsecretsapp_types.go b/api/v1beta1/hcpvaultsecretsapp_types.go
index 4c9500f6..39170ad6 100644
--- a/api/v1beta1/hcpvaultsecretsapp_types.go
+++ b/api/v1beta1/hcpvaultsecretsapp_types.go
@@ -32,6 +32,37 @@ type HCPVaultSecretsAppSpec struct {
// Destination provides configuration necessary for syncing the HCP Vault
// Application secrets to Kubernetes.
Destination Destination `json:"destination"`
+ // SyncConfig configures sync behavior from HVS to VSO
+ SyncConfig *HVSSyncConfig `json:"syncConfig,omitempty"`
+}
+
+// HVSSyncConfig configures sync behavior from HVS to VSO
+type HVSSyncConfig struct {
+ // Dynamic configures sync behavior for dynamic secrets.
+ Dynamic *HVSDynamicSyncConfig `json:"dynamic,omitempty"`
+}
+
+// HVSDynamicSyncConfig configures sync behavior for HVS dynamic secrets.
+type HVSDynamicSyncConfig struct {
+ // RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
+ // new secrets are generated. Defaults to 67 percent minus jitter.
+ // +kubebuilder:default=67
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=100
+ RenewalPercent int `json:"renewalPercent,omitempty"`
+}
+
+// HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
+// Vault Secrets App
+type HVSDynamicStatus struct {
+ // Name of the dynamic secret
+ Name string `json:"name,omitempty"`
+ // CreatedAt is the timestamp string of when the dynamic secret was created
+ CreatedAt string `json:"createdAt,omitempty"`
+ // ExpiresAt is the timestamp string of when the dynamic secret will expire
+ ExpiresAt string `json:"expiresAt,omitempty"`
+ // TTL is the time-to-live of the dynamic secret in seconds
+ TTL string `json:"ttl,omitempty"`
}
// HCPVaultSecretsAppStatus defines the observed state of HCPVaultSecretsApp
@@ -47,6 +78,9 @@ type HCPVaultSecretsAppStatus struct {
// The SecretMac is also used to detect drift in the Destination Secret's Data.
// If drift is detected the data will be synced to the Destination.
SecretMAC string `json:"secretMAC,omitempty"`
+ // DynamicSecrets lists the last observed state of any dynamic secrets
+ // within the HCP Vault Secrets App
+ DynamicSecrets []HVSDynamicStatus `json:"dynamicSecrets,omitempty"`
}
//+kubebuilder:object:root=true
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 7e43e268..dd288c0e 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -167,7 +167,7 @@ func (in *HCPVaultSecretsApp) DeepCopyInto(out *HCPVaultSecretsApp) {
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
- out.Status = in.Status
+ in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HCPVaultSecretsApp.
@@ -229,6 +229,11 @@ func (in *HCPVaultSecretsAppSpec) DeepCopyInto(out *HCPVaultSecretsAppSpec) {
copy(*out, *in)
}
in.Destination.DeepCopyInto(&out.Destination)
+ if in.SyncConfig != nil {
+ in, out := &in.SyncConfig, &out.SyncConfig
+ *out = new(HVSSyncConfig)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HCPVaultSecretsAppSpec.
@@ -244,6 +249,11 @@ func (in *HCPVaultSecretsAppSpec) DeepCopy() *HCPVaultSecretsAppSpec {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HCPVaultSecretsAppStatus) DeepCopyInto(out *HCPVaultSecretsAppStatus) {
*out = *in
+ if in.DynamicSecrets != nil {
+ in, out := &in.DynamicSecrets, &out.DynamicSecrets
+ *out = make([]HVSDynamicStatus, len(*in))
+ copy(*out, *in)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HCPVaultSecretsAppStatus.
@@ -256,6 +266,56 @@ func (in *HCPVaultSecretsAppStatus) DeepCopy() *HCPVaultSecretsAppStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HVSDynamicStatus) DeepCopyInto(out *HVSDynamicStatus) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HVSDynamicStatus.
+func (in *HVSDynamicStatus) DeepCopy() *HVSDynamicStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(HVSDynamicStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HVSDynamicSyncConfig) DeepCopyInto(out *HVSDynamicSyncConfig) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HVSDynamicSyncConfig.
+func (in *HVSDynamicSyncConfig) DeepCopy() *HVSDynamicSyncConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(HVSDynamicSyncConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HVSSyncConfig) DeepCopyInto(out *HVSSyncConfig) {
+ *out = *in
+ if in.Dynamic != nil {
+ in, out := &in.Dynamic, &out.Dynamic
+ *out = new(HVSDynamicSyncConfig)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HVSSyncConfig.
+func (in *HVSSyncConfig) DeepCopy() *HVSSyncConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(HVSSyncConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MergeStrategy) DeepCopyInto(out *MergeStrategy) {
*out = *in
diff --git a/chart/crds/secrets.hashicorp.com_hcpvaultsecretsapps.yaml b/chart/crds/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
index 801894f2..d3521763 100644
--- a/chart/crds/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
+++ b/chart/crds/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
@@ -244,6 +244,22 @@ spec:
- name
type: object
type: array
+ syncConfig:
+ description: SyncConfig configures sync behavior from HVS to VSO
+ properties:
+ dynamic:
+ description: Dynamic configures sync behavior for dynamic secrets.
+ properties:
+ renewalPercent:
+ default: 67
+ description: |-
+ RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
+ new secrets are generated. Defaults to 67 percent minus jitter.
+ maximum: 100
+ minimum: 0
+ type: integer
+ type: object
+ type: object
required:
- appName
- destination
@@ -251,6 +267,32 @@ spec:
status:
description: HCPVaultSecretsAppStatus defines the observed state of HCPVaultSecretsApp
properties:
+ dynamicSecrets:
+ description: |-
+ DynamicSecrets lists the last observed state of any dynamic secrets
+ within the HCP Vault Secrets App
+ items:
+ description: |-
+ HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
+ Vault Secrets App
+ properties:
+ createdAt:
+ description: CreatedAt is the timestamp string of when the dynamic
+ secret was created
+ type: string
+ expiresAt:
+ description: ExpiresAt is the timestamp string of when the dynamic
+ secret will expire
+ type: string
+ name:
+ description: Name of the dynamic secret
+ type: string
+ ttl:
+ description: TTL is the time-to-live of the dynamic secret in
+ seconds
+ type: string
+ type: object
+ type: array
lastGeneration:
description: LastGeneration is the Generation of the last reconciled
resource.
diff --git a/config/crd/bases/secrets.hashicorp.com_hcpvaultsecretsapps.yaml b/config/crd/bases/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
index 801894f2..d3521763 100644
--- a/config/crd/bases/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
+++ b/config/crd/bases/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
@@ -244,6 +244,22 @@ spec:
- name
type: object
type: array
+ syncConfig:
+ description: SyncConfig configures sync behavior from HVS to VSO
+ properties:
+ dynamic:
+ description: Dynamic configures sync behavior for dynamic secrets.
+ properties:
+ renewalPercent:
+ default: 67
+ description: |-
+ RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
+ new secrets are generated. Defaults to 67 percent minus jitter.
+ maximum: 100
+ minimum: 0
+ type: integer
+ type: object
+ type: object
required:
- appName
- destination
@@ -251,6 +267,32 @@ spec:
status:
description: HCPVaultSecretsAppStatus defines the observed state of HCPVaultSecretsApp
properties:
+ dynamicSecrets:
+ description: |-
+ DynamicSecrets lists the last observed state of any dynamic secrets
+ within the HCP Vault Secrets App
+ items:
+ description: |-
+ HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
+ Vault Secrets App
+ properties:
+ createdAt:
+ description: CreatedAt is the timestamp string of when the dynamic
+ secret was created
+ type: string
+ expiresAt:
+ description: ExpiresAt is the timestamp string of when the dynamic
+ secret will expire
+ type: string
+ name:
+ description: Name of the dynamic secret
+ type: string
+ ttl:
+ description: TTL is the time-to-live of the dynamic secret in
+ seconds
+ type: string
+ type: object
+ type: array
lastGeneration:
description: LastGeneration is the Generation of the last reconciled
resource.
diff --git a/controllers/hcpvaultsecretsapp_controller.go b/controllers/hcpvaultsecretsapp_controller.go
index e5be98a5..8dd16954 100644
--- a/controllers/hcpvaultsecretsapp_controller.go
+++ b/controllers/hcpvaultsecretsapp_controller.go
@@ -12,6 +12,7 @@ import (
httptransport "github.com/go-openapi/runtime/client"
hvsclient "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service"
+ "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models"
hcpconfig "github.com/hashicorp/hcp-sdk-go/config"
hcpclient "github.com/hashicorp/hcp-sdk-go/httpclient"
"github.com/hashicorp/hcp-sdk-go/profile"
@@ -41,6 +42,14 @@ const (
headerUserAgent = "User-Agent"
hcpVaultSecretsAppFinalizer = "hcpvaultsecretsapp.secrets.hashicorp.com/finalizer"
+
+ // defaultDyanmicRenewPercent is the default renewal point in the dynamic
+ // secret's TTL, expressed as a percent out of 100
+ defaultDyanmicRenewPercent = 67
+
+ // defaultDynamicRequeue is for use when a dynamic secret needs to be
+ // renewed ASAP so we need a requeue time that's not zero
+ defaultDynamicRequeue = 1 * time.Second
)
var userAgent = fmt.Sprintf("vso/%s", version.Version().String())
@@ -100,9 +109,7 @@ func (r *HCPVaultSecretsAppReconciler) Reconcile(ctx context.Context, req ctrl.R
"Field validation failed, err=%s", err)
return ctrl.Result{}, err
}
- if d.Seconds() > 0 {
- requeueAfter = computeHorizonWithJitter(d)
- }
+ requeueAfter = d
}
transOption, err := helpers.NewSecretTransformationOption(ctx, r.Client, o, r.GlobalTransformationOptions)
@@ -131,25 +138,46 @@ func (r *HCPVaultSecretsAppReconciler) Reconcile(ctx context.Context, req ctrl.R
resp, err := c.OpenAppSecrets(params, nil)
if err != nil {
- logger.Error(err, "Get App Secret", "appName", o.Spec.AppName)
+ logger.Error(err, "Get App Secrets", "appName", o.Spec.AppName)
entry, _ := r.BackOffRegistry.Get(req.NamespacedName)
return ctrl.Result{
RequeueAfter: entry.NextBackOff(),
}, nil
- } else {
- r.BackOffRegistry.Delete(req.NamespacedName)
+ }
+
+ dynamicSecrets, err := getHVSDynamicSecrets(ctx, c, o.Spec.AppName)
+ if err != nil {
+ logger.Error(err, "Get Dynamic Secrets", "appName", o.Spec.AppName)
+ entry, _ := r.BackOffRegistry.Get(req.NamespacedName)
+ return ctrl.Result{
+ RequeueAfter: entry.NextBackOff(),
+ }, nil
+ }
+ // Add the dynamic secrets to the OpenAppSecrets response to be processed
+ // along with the rest of the App secrets
+ resp.Payload.Secrets = append(resp.Payload.Secrets, dynamicSecrets...)
+
+ // Remove this app from the backoff registry now that we're done with HVS
+ // API calls
+ r.BackOffRegistry.Delete(req.NamespacedName)
+
+ // Calculate next requeue time based on whichever comes first between the
+ // current `requeueAfter` and all the dynamic secret renewal times. Also set
+ // the dynamic secret statuses while looping through the dynamic secrets.
+ renewPercent := getDynamicRenewPercent(o.Spec.SyncConfig)
+ o.Status.DynamicSecrets = nil
+ for _, secret := range dynamicSecrets {
+ requeueAfter = getNextRequeue(requeueAfter, secret.DynamicInstance, renewPercent, time.Now())
+ o.Status.DynamicSecrets = append(o.Status.DynamicSecrets, makeHVSDynamicStatus(secret))
+ }
+ if requeueAfter.Seconds() > 0 {
+ requeueAfter = computeHorizonWithJitter(requeueAfter)
}
r.referenceCache.Set(SecretTransformation, req.NamespacedName,
helpers.GetTransformationRefObjKeys(
o.Spec.Destination.Transformation, o.Namespace)...)
- if err != nil {
- r.Recorder.Eventf(o, corev1.EventTypeWarning, consts.ReasonTransformationError,
- "Failed setting up SecretTransformationOption: %s", err)
- return ctrl.Result{RequeueAfter: computeHorizonWithJitter(requeueDurationOnError)}, nil
- }
-
data, err := r.SecretDataBuilder.WithHVSAppSecrets(resp, transOption)
if err != nil {
r.Recorder.Eventf(o, corev1.EventTypeWarning, consts.ReasonSecretDataBuilderError,
@@ -319,3 +347,93 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
func injectRequestInformation(runtime *httptransport.Runtime) {
runtime.Transport = &transport{child: runtime.Transport}
}
+
+// getHVSDynamicSecrets returns the "open" dynamic secrets for the given HVS app
+func getHVSDynamicSecrets(ctx context.Context, c hvsclient.ClientService, appName string) ([]*models.Secrets20231128OpenSecret, error) {
+ // Fetch the unopened AppSecrets to get the full list of secrets (including
+ // dynamic)
+ secretsListParams := &hvsclient.ListAppSecretsParams{
+ Context: ctx,
+ AppName: appName,
+ // Type is currently non-functional, so we have to filter the list
+ // ourselves
+ // Type: ptr.To(helpers.HVSSecretTypeDynamic),
+ }
+ appSecretsList, err := c.ListAppSecrets(secretsListParams, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list app Secrets for app %s: %w", appName, err)
+ }
+
+ dynamicSecrets := make([]*models.Secrets20231128OpenSecret, 0)
+
+ if appSecretsList.Payload != nil {
+ // TODO(tvoran): only fetch/create dynamic secrets that are new or are
+ // due for rotation
+ for _, appSecret := range appSecretsList.Payload.Secrets {
+ if appSecret.Type != helpers.HVSSecretTypeDynamic {
+ continue
+ }
+ secretParams := &hvsclient.OpenAppSecretParams{
+ Context: ctx,
+ AppName: appName,
+ SecretName: appSecret.Name,
+ }
+ dynamicResp, err := c.OpenAppSecret(secretParams, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get dynamic secret %s in app %s: %w",
+ appSecret.Name, appName, err)
+ }
+ if dynamicResp != nil && dynamicResp.Payload != nil {
+ dynamicSecrets = append(dynamicSecrets, dynamicResp.Payload.Secret)
+ }
+ }
+ }
+
+ return dynamicSecrets, nil
+}
+
+// getDynamicRenewPercent returns the default renewal percent or the synconfig
+// dynamic renewal percent in that order of precendence
+func getDynamicRenewPercent(syncConfig *secretsv1beta1.HVSSyncConfig) int {
+ renewPercent := defaultDyanmicRenewPercent
+ if syncConfig != nil && syncConfig.Dynamic != nil && syncConfig.Dynamic.RenewalPercent != 0 {
+ renewPercent = syncConfig.Dynamic.RenewalPercent
+ }
+ return renewPercent
+}
+
+func makeHVSDynamicStatus(secret *models.Secrets20231128OpenSecret) secretsv1beta1.HVSDynamicStatus {
+ status := secretsv1beta1.HVSDynamicStatus{
+ Name: secret.Name,
+ }
+ if secret.DynamicInstance != nil {
+ status.CreatedAt = secret.DynamicInstance.CreatedAt.String()
+ status.ExpiresAt = secret.DynamicInstance.ExpiresAt.String()
+ status.TTL = secret.DynamicInstance.TTL
+ }
+ return status
+}
+
+// getNextRequeue returns whichever is less between the current `requeueAfter`
+// and the next renewal time of the dynamic secret instance
+func getNextRequeue(requeueAfter time.Duration, dynamicInstance *models.Secrets20231128OpenSecretDynamicInstance, renewPercent int, now time.Time) time.Duration {
+ if dynamicInstance == nil {
+ return requeueAfter
+ }
+ nextRequeue := requeueAfter
+
+ // Calculate the time when the dynamic secret should be renewed
+ fullTTL := time.Time(dynamicInstance.ExpiresAt).Sub(time.Time(dynamicInstance.CreatedAt))
+ renewPoint := fullTTL * time.Duration(renewPercent) / 100
+ renewTime := time.Time(dynamicInstance.CreatedAt).Add(renewPoint)
+ howLongUntilRenewTime := renewTime.Sub(now)
+
+ if howLongUntilRenewTime < requeueAfter || requeueAfter == 0 {
+ nextRequeue = howLongUntilRenewTime
+ }
+ if nextRequeue <= 0 {
+ nextRequeue = defaultDynamicRequeue
+ }
+
+ return nextRequeue
+}
diff --git a/controllers/hcpvaultsecretsapp_controller_test.go b/controllers/hcpvaultsecretsapp_controller_test.go
new file mode 100644
index 00000000..17b88221
--- /dev/null
+++ b/controllers/hcpvaultsecretsapp_controller_test.go
@@ -0,0 +1,255 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+
+package controllers
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/go-openapi/runtime"
+ "github.com/go-openapi/strfmt"
+ hvsclient "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service"
+ "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models"
+ secretsv1beta1 "github.com/hashicorp/vault-secrets-operator/api/v1beta1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var _ runtime.ClientTransport = (*fakeHVSTransport)(nil)
+
+// fakeHVSTransport is used to fake responses from HVS in tests.
+type fakeHVSTransport struct {
+ secrets []*models.Secrets20231128OpenSecret
+}
+
+func (f *fakeHVSTransport) Submit(operation *runtime.ClientOperation) (interface{}, error) {
+ if operation.ID == "ListAppSecrets" {
+ respSecrets := []*models.Secrets20231128Secret{}
+ for _, secret := range f.secrets {
+ mb, err := secret.MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+ closedSecret := &models.Secrets20231128Secret{}
+ err = closedSecret.UnmarshalBinary(mb)
+ if err != nil {
+ return nil, err
+ }
+ respSecrets = append(respSecrets, closedSecret)
+ }
+ return &hvsclient.ListAppSecretsOK{
+ Payload: &models.Secrets20231128ListAppSecretsResponse{
+ Secrets: respSecrets,
+ },
+ }, nil
+ }
+
+ if operation.ID == "OpenAppSecret" {
+ params := operation.Params.(*hvsclient.OpenAppSecretParams)
+ for _, secret := range f.secrets {
+ if secret.Name == params.SecretName {
+ return &hvsclient.OpenAppSecretOK{
+ Payload: &models.Secrets20231128OpenAppSecretResponse{
+ Secret: secret,
+ },
+ }, nil
+ }
+ }
+ return nil, fmt.Errorf("secret %q not found", params.SecretName)
+ }
+
+ return nil, fmt.Errorf("unsupported operation ID: %s", operation.ID)
+}
+
+func newFakeHVSTransport(secrets []*models.Secrets20231128OpenSecret) *fakeHVSTransport {
+ return &fakeHVSTransport{secrets: secrets}
+}
+
+func Test_getHVSDynamicSecrets(t *testing.T) {
+ t.Parallel()
+
+ exampleStatic := &models.Secrets20231128OpenSecret{
+ Name: "static",
+ StaticVersion: &models.Secrets20231128OpenSecretStaticVersion{
+ Value: "value",
+ },
+ Type: "static",
+ }
+
+ exampleDynamic1 := &models.Secrets20231128OpenSecret{
+ Name: "dynamic1",
+ DynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ Values: map[string]string{
+ "secret_key": "key1",
+ "secret_id": "id1",
+ },
+ },
+ Type: "dynamic",
+ }
+
+ exampleDynamic2 := &models.Secrets20231128OpenSecret{
+ Name: "dynamic2",
+ DynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ Values: map[string]string{
+ "secret_key": "key2",
+ "secret_id": "id2",
+ },
+ },
+ Type: "dynamic",
+ }
+
+ tests := map[string]struct {
+ hvsSecrets []*models.Secrets20231128OpenSecret
+ expected []*models.Secrets20231128OpenSecret
+ }{
+ "mixed": {
+ hvsSecrets: []*models.Secrets20231128OpenSecret{
+ exampleStatic,
+ exampleDynamic1,
+ exampleDynamic2,
+ },
+ expected: []*models.Secrets20231128OpenSecret{
+ exampleDynamic1,
+ exampleDynamic2,
+ },
+ },
+ "no dynamic": {
+ hvsSecrets: []*models.Secrets20231128OpenSecret{
+ exampleStatic,
+ },
+ expected: []*models.Secrets20231128OpenSecret{},
+ },
+ }
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ transport := newFakeHVSTransport(tt.hvsSecrets)
+ client := hvsclient.New(transport, nil)
+ resp, err := getHVSDynamicSecrets(context.Background(), client, "appName")
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, resp)
+ })
+ }
+}
+
+func Test_getNextRequeue(t *testing.T) {
+ now := time.Now()
+
+ tests := map[string]struct {
+ requeueAfter time.Duration
+ dynamicInstance *models.Secrets20231128OpenSecretDynamicInstance
+ renewPercent int
+ expected time.Duration
+ }{
+ "new dynamic secret": {
+ // A new dynamic secret is being evaluated, and its renewal is
+ // before next requeueAfter
+ requeueAfter: 2 * time.Hour,
+ dynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ CreatedAt: strfmt.DateTime(now),
+ ExpiresAt: strfmt.DateTime(now.Add(1 * time.Hour)),
+ TTL: "3600s",
+ },
+ renewPercent: defaultDyanmicRenewPercent,
+ expected: time.Duration(40*time.Minute + 12*time.Second), // 1h*0.67
+ },
+ "mid-ttl of the dynamic secret": {
+ // The dynamic secret is halfway through its TTL, and the its
+ // renewal should come before the current requeueAfter, so the
+ // expected renewal time is 82% of the TTL (49m12s) minus the time
+ // since the secret was created (30m).
+ requeueAfter: 2 * time.Hour,
+ dynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ CreatedAt: strfmt.DateTime(now.Add(-30 * time.Minute)),
+ ExpiresAt: strfmt.DateTime(now.Add(30 * time.Minute)),
+ TTL: "3600s",
+ },
+ renewPercent: 82,
+ expected: time.Duration(19*time.Minute + 12*time.Second), // 1h*0.82 - 30m
+ },
+ "requeueAfter is shorter": {
+ requeueAfter: 1 * time.Hour,
+ dynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ CreatedAt: strfmt.DateTime(now),
+ ExpiresAt: strfmt.DateTime(now.Add(2 * time.Hour)),
+ TTL: "7200s",
+ },
+ renewPercent: defaultDyanmicRenewPercent,
+ expected: 1 * time.Hour,
+ },
+ "expired dynamic secret": {
+ // Somehow this dynamic secret expired an hour ago, so requeue
+ // immediately.
+ requeueAfter: 1 * time.Hour,
+ dynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ CreatedAt: strfmt.DateTime(now.Add(-2 * time.Hour)),
+ ExpiresAt: strfmt.DateTime(now.Add(-1 * time.Hour)),
+ TTL: "3600s",
+ },
+ renewPercent: defaultDyanmicRenewPercent,
+ expected: defaultDynamicRequeue,
+ },
+ "future dynamic secret": {
+ requeueAfter: 1 * time.Hour,
+ dynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ CreatedAt: strfmt.DateTime(now.Add(1 * time.Hour)),
+ ExpiresAt: strfmt.DateTime(now.Add(2 * time.Hour)),
+ TTL: "3600s",
+ },
+ renewPercent: defaultDyanmicRenewPercent,
+ expected: 1 * time.Hour,
+ },
+ "reqeueAfter is zero": {
+ requeueAfter: 0,
+ dynamicInstance: &models.Secrets20231128OpenSecretDynamicInstance{
+ CreatedAt: strfmt.DateTime(now),
+ ExpiresAt: strfmt.DateTime(now.Add(1 * time.Hour)),
+ TTL: "3600s",
+ },
+ renewPercent: defaultDyanmicRenewPercent,
+ expected: time.Duration(40*time.Minute + 12*time.Second), // 1h*0.67
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := getNextRequeue(tc.requeueAfter, tc.dynamicInstance, tc.renewPercent, now)
+ assert.Equal(t, tc.expected, got)
+ })
+ }
+}
+
+func Test_makeDynamicRenewPercent(t *testing.T) {
+ tests := map[string]struct {
+ syncConfig *secretsv1beta1.HVSSyncConfig
+ expected int
+ }{
+ "syncConfig is nil": {
+ syncConfig: nil,
+ expected: defaultDyanmicRenewPercent,
+ },
+ "syncConfig.Dynamic is nil": {
+ syncConfig: &secretsv1beta1.HVSSyncConfig{
+ Dynamic: nil,
+ },
+ expected: defaultDyanmicRenewPercent,
+ },
+ "syncConfig.Dynamic not nil": {
+ syncConfig: &secretsv1beta1.HVSSyncConfig{
+ Dynamic: &secretsv1beta1.HVSDynamicSyncConfig{
+ RenewalPercent: 42,
+ },
+ },
+ expected: 42,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := getDynamicRenewPercent(tc.syncConfig)
+ assert.Equal(t, tc.expected, got)
+ })
+ }
+}
diff --git a/docs/api/api-reference.md b/docs/api/api-reference.md
index 36ec2163..aeaf38c8 100644
--- a/docs/api/api-reference.md
+++ b/docs/api/api-reference.md
@@ -187,10 +187,63 @@ _Appears in:_
| `refreshAfter` _string_ | RefreshAfter a period of time, in duration notation e.g. 30s, 1m, 24h | 600s | Pattern: `^([0-9]+(\.[0-9]+)?(s|m|h))$`
Type: string
|
| `rolloutRestartTargets` _[RolloutRestartTarget](#rolloutrestarttarget) array_ | RolloutRestartTargets should be configured whenever the application(s)
consuming the HCP Vault Secrets App does not support dynamically reloading a
rotated secret. In that case one, or more RolloutRestartTarget(s) can be
configured here. The Operator will trigger a "rollout-restart" for each target
whenever the Vault secret changes between reconciliation events. See
RolloutRestartTarget for more details. | | |
| `destination` _[Destination](#destination)_ | Destination provides configuration necessary for syncing the HCP Vault
Application secrets to Kubernetes. | | |
+| `syncConfig` _[HVSSyncConfig](#hvssyncconfig)_ | SyncConfig configures sync behavior from HVS to VSO | | |
+#### HVSDynamicStatus
+
+
+
+HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
+Vault Secrets App
+
+
+
+_Appears in:_
+- [HCPVaultSecretsAppStatus](#hcpvaultsecretsappstatus)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name of the dynamic secret | | |
+| `createdAt` _string_ | CreatedAt is the timestamp string of when the dynamic secret was created | | |
+| `expiresAt` _string_ | ExpiresAt is the timestamp string of when the dynamic secret will expire | | |
+| `ttl` _string_ | TTL is the time-to-live of the dynamic secret in seconds | | |
+
+
+#### HVSDynamicSyncConfig
+
+
+
+HVSDynamicSyncConfig configures sync behavior for HVS dynamic secrets.
+
+
+
+_Appears in:_
+- [HVSSyncConfig](#hvssyncconfig)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `renewalPercent` _integer_ | RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
new secrets are generated. Defaults to 67 percent minus jitter. | 67 | Maximum: 100
Minimum: 0
|
+
+
+#### HVSSyncConfig
+
+
+
+HVSSyncConfig configures sync behavior from HVS to VSO
+
+
+
+_Appears in:_
+- [HCPVaultSecretsAppSpec](#hcpvaultsecretsappspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `dynamic` _[HVSDynamicSyncConfig](#hvsdynamicsyncconfig)_ | Dynamic configures sync behavior for dynamic secrets. | | |
+
+
#### MergeStrategy
diff --git a/helpers/secrets.go b/helpers/secrets.go
index 1544a4a5..502c82f8 100644
--- a/helpers/secrets.go
+++ b/helpers/secrets.go
@@ -28,6 +28,7 @@ const (
SecretDataKeyRaw = "_raw"
HVSSecretTypeKV = "kv"
HVSSecretTypeRotating = "rotating"
+ HVSSecretTypeDynamic = "dynamic"
)
var SecretDataErrorContainsRaw = fmt.Errorf("key '%s' not permitted in Secret data", SecretDataKeyRaw)
@@ -498,7 +499,7 @@ func (s *SecretDataBuilder) WithHVSAppSecrets(resp *hvsclient.OpenAppSecretsOK,
data := make(map[string][]byte)
hasTemplates := len(opt.KeyedTemplates) > 0
for _, v := range p.Secrets {
- if v.StaticVersion == nil && v.RotatingVersion == nil {
+ if v.StaticVersion == nil && v.RotatingVersion == nil && v.DynamicInstance == nil {
continue
}
@@ -508,9 +509,16 @@ func (s *SecretDataBuilder) WithHVSAppSecrets(resp *hvsclient.OpenAppSecretsOK,
case HVSSecretTypeRotating:
// Since rotating secrets have multiple values, prefix each key with
// the secret name to avoid collisions.
- for rvk, rvv := range v.RotatingVersion.Values {
- rName := fmt.Sprintf("%s_%s", v.Name, rvk)
- secrets[rName] = rvv
+ for rotatingKey, rotatingValue := range v.RotatingVersion.Values {
+ prefixedKey := fmt.Sprintf("%s_%s", v.Name, rotatingKey)
+ secrets[prefixedKey] = rotatingValue
+ }
+ case HVSSecretTypeDynamic:
+ // Since dynamic secrets have multiple values, prefix each key with
+ // the secret name to avoid collisions.
+ for dynamicKey, dynamicValue := range v.DynamicInstance.Values {
+ prefixedKey := fmt.Sprintf("%s_%s", v.Name, dynamicKey)
+ secrets[prefixedKey] = dynamicValue
}
default:
continue