Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,26 @@ mappings:
secretName: k8s-secretname
vaultEngineType: # optionally "kv" or "kv-v2" to override the defaultEngineType specified above
secretType: Opaque # optionally - default "Opaque" e.g.: "kubernetes.io/tls"
additionalSecretLabels: # optionally add labels to the secret
environment: dev
team: core-services
# mappings from google secrets manager paths to kubernetes secret names
- sourceType: gsm
path: projects/my-project/secrets/my-secret/versions/latest
secretName: my-secret
- sourceType: gsm
path: projects/my-project/secrets/my-other-secret
secretName: defaults-to-latest-version
additionalSecretLabels:
environment: dev
team: core-services
```

### Labels and Reconciliation
By default, Pentagon will add a [metadata label](https://godoc.org/k8s.io/apimachinery/pkg/apis/meta/v1#ObjectMeta) with the key `pentagon` and the value `default`. At the least, this helps identify Pentagon as the creator and maintainer of the secret.

You can also specify custom labels for each secret mapping using the `additionalSecretLabels` filed. These labels will be added to the Kubernetes secret alongside the required `pentagon` label.

If you set the `label` configuration parameter, you can control the value of the label, allowing multiple Pentagon instances to exist without stepping on each other. Setting a non-default `label` also enables reconciliation which will cleanup any secrets that were created by Pentagon with a matching label, but are no longer present in the `mappings` configuration. This provides a simple way to ensure that old secret data does not remain present in your system after its time has passed.

### About Vault Engine Types
Expand Down
4 changes: 4 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,8 @@ type Mapping struct {
// use for this secret's value in cases where gsmEncodingType is *not* json. If
// this is unset, the key name will default to the value of secretName.
GSMSecretKeyValue string `yaml:"gsmSecretKeyValue"`

// AdditionalSecretLabels allows you to specify the additional labels that will be
// added to the created Kubernetes secret.
AdditionalSecretLabels map[string]string `yaml:"additionalSecretLabels"`
}
7 changes: 6 additions & 1 deletion reflector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"maps"

"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -186,11 +187,15 @@ func (r *Reflector) getGSMSecret(ctx context.Context, mapping Mapping) (map[stri
}

func (r *Reflector) createK8sSecret(ctx context.Context, mapping Mapping, data map[string][]byte) error {
labels := make(map[string]string)
maps.Copy(labels, mapping.AdditionalSecretLabels)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is equivalent to labels := maps.Clone(mapping.AdditionalSecretLabels)

Another option would be to only replace the make call with the map literal it's replacing (map[string]string{LabelKey: r.labelValue}). Then it's clear that we don't expect to have additional labels most of the time.

Copy link
Author

@jamiedel818 jamiedel818 Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. I just pushed a change that uses the latter approach you suggested to maintain clarity.

labels[LabelKey] = r.labelValue

secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: mapping.SecretName,
Namespace: r.k8sNamespace,
Labels: map[string]string{LabelKey: r.labelValue},
Labels: labels,
},
Data: data,
Type: mapping.SecretType,
Expand Down
128 changes: 118 additions & 10 deletions reflector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"testing"

"maps"

v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -67,11 +69,10 @@ func TestReflectorSimple(t *testing.T) {
t.Fatalf("secret should be there: %s", err)
}

if secret.Labels[LabelKey] != DefaultLabelValue {
t.Fatalf(
"secret pentagon label should be %s is %s",
DefaultLabelValue,
secret.Labels[LabelKey],
// no additional labels provided. check for default
if !maps.Equal(secret.Labels, map[string]string{LabelKey: DefaultLabelValue}) {
t.Fatalf("labels do not match: got %v, want %v", secret.Labels,
map[string]string{LabelKey: DefaultLabelValue},
)
}

Expand Down Expand Up @@ -120,11 +121,58 @@ func TestReflectorGSM(t *testing.T) {
t.Fatalf("secret should be there: %s", err)
}

if secret.Labels[LabelKey] != DefaultLabelValue {
t.Fatalf(
"secret pentagon label should be %s is %s",
DefaultLabelValue,
secret.Labels[LabelKey],
// no additional labels provided. check for default
if !maps.Equal(secret.Labels, map[string]string{LabelKey: DefaultLabelValue}) {
t.Fatalf("labels do not match: got %v, want %v", secret.Labels,
map[string]string{LabelKey: DefaultLabelValue},
)
}

if string(secret.Data["foo-key"]) != "foo_bar_latest" {
t.Fatalf("secret value does not equal foo_bar_latest: %s", string(secret.Data["foo"]))
}
}

func TestReflectorAdditionalSecretLabelsGSM(t *testing.T) {
ctx := context.Background()
k8sClient := k8sfake.NewSimpleClientset()

gsm := gsm.NewMockGSM(map[string][]byte{
"projects/foo/secrets/bar/versions/latest": []byte("foo_bar_latest"),
})

r := NewReflector(
nil,
gsm,
k8sClient, DefaultNamespace,
DefaultLabelValue,
)

err := r.Reflect(ctx, []Mapping{
{
SourceType: "gsm",
Path: "projects/foo/secrets/bar/versions/latest",
SecretName: "foo",
GSMSecretKeyValue: "foo-key",
AdditionalSecretLabels: map[string]string{"secret": "foo"},
},
})
if err != nil {
t.Fatalf("reflect didn't work: %s", err)
}

// now get the secret out of k8s
secrets := k8sClient.CoreV1().Secrets(DefaultNamespace)

secret, err := secrets.Get(ctx, "foo", metav1.GetOptions{})
if err != nil {
t.Fatalf("secret should be there: %s", err)
}

// check additional labels and default
if !maps.Equal(secret.Labels, map[string]string{LabelKey: DefaultLabelValue, "secret": "foo"}) {
t.Fatalf("labels do not match: got %v, want %v", secret.Labels,
map[string]string{LabelKey: DefaultLabelValue, "secret": "foo"},
)
}

Expand All @@ -133,6 +181,66 @@ func TestReflectorGSM(t *testing.T) {
}
}

func TestReflectorAdditionalSecretLabelsVault(t *testing.T) {
allEngineTest(t, func(t testing.TB, engineType vault.EngineType) {
ctx := context.Background()

k8sClient := k8sfake.NewSimpleClientset()
vaultClient := vault.NewMock(map[string]vault.EngineType{
"secrets": engineType,
})

data := map[string]interface{}{
"foo": "bar",
"bar": "baz",
}
vaultClient.Write("secrets/data/foo", data)

r := NewReflector(
vaultClient,
gsm.NewMockGSM(nil),
k8sClient, DefaultNamespace,
DefaultLabelValue,
)

err := r.Reflect(ctx, []Mapping{
{
SourceType: "vault",
Path: "secrets/data/foo",
SecretName: "foo",
VaultEngineType: engineType,
AdditionalSecretLabels: map[string]string{"secret": "foo"},
},
})
if err != nil {
t.Fatalf("reflect didn't work: %s", err)
}

// now get the secret out of k8s
secrets := k8sClient.CoreV1().Secrets(DefaultNamespace)

secret, err := secrets.Get(ctx, "foo", metav1.GetOptions{})
if err != nil {
t.Fatalf("secret should be there: %s", err)
}

// check additional labels and default
if !maps.Equal(secret.Labels, map[string]string{LabelKey: DefaultLabelValue, "secret": "foo"}) {
t.Fatalf("labels do not match: got %v, want %v", secret.Labels,
map[string]string{LabelKey: DefaultLabelValue, "secret": "foo"},
)
}

if string(secret.Data["foo"]) != "bar" {
t.Fatalf("secret value does not equal bar: %s", string(secret.Data["foo"]))
}

if string(secret.Data["bar"]) != "baz" {
t.Fatalf("secret value does not equal baz: %s", string(secret.Data["bar"]))
}
})
}

func TestReflectorGSMJSONStruct(t *testing.T) {
ctx := context.Background()
k8sClient := k8sfake.NewSimpleClientset()
Expand Down