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