diff --git a/docs/pages/reference/machine-id/configuration.mdx b/docs/pages/reference/machine-id/configuration.mdx index 36d57ac3898ee..38f08280b6a80 100644 --- a/docs/pages/reference/machine-id/configuration.mdx +++ b/docs/pages/reference/machine-id/configuration.mdx @@ -1336,8 +1336,19 @@ Configuration: # destination, this will always be `kubernetes_secret`. type: kubernetes_secret # name specifies the name of the Kubernetes Secret to write the artifacts to. -# This must be in the same namespace that `tbot` is running in. name: my-secret +# namespace specifies the Kubernetes namespace that the secret should be written +# to. If unspecified, this defaults to the value of the `POD_NAMESPACE` +# environment variable. +# +# When using the Helm chart, and specifying a namespace other than the one that +# `tbot` is running in, you must manually grant the `tbot` service account +# privileges to read and write to secrets in that namespace. +namespace: +# labels specifies the labels to apply to the Kubernetes Secret. This field is +# optional. +labels: + example: "foo" ``` ## Bot resource diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index 253f10563c71e..9407914ffbb22 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -526,25 +526,19 @@ func DestinationFromURI(uriString string) (destination.Destination, error) { } return &destination.Memory{}, nil case "kubernetes-secret": - if uri.Host != "" { - return nil, trace.BadParameter( - "kubernetes-secret scheme should not be specified with host", - ) - } if uri.Path == "" { return nil, trace.BadParameter( "kubernetes-secret scheme should have a path specified", ) } // kubernetes-secret:///my-secret - // TODO(noah): Eventually we'll support namespace in the host part of - // the URI. For now, we'll default to the namespace tbot is running in. // Path will be prefixed with '/' so we'll strip it off. secretName := strings.TrimPrefix(uri.Path, "/") return &k8s.SecretDestination{ - Name: secretName, + Name: secretName, + Namespace: uri.Host, }, nil default: return nil, trace.BadParameter( diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go index 145ce3ab2a43c..3422c74c01ccb 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -169,11 +169,15 @@ func TestDestinationFromURI(t *testing.T) { }, }, { - in: "kubernetes-secret://my-secret", + in: "kubernetes-secret://my-secret", + wantErr: true, + }, + { + in: "kubernetes-secret://my-namespace/my-secret", want: &k8s.SecretDestination{ - Name: "my-secret", + Name: "my-secret", + Namespace: "my-namespace", }, - wantErr: true, }, } for _, tt := range tests { diff --git a/lib/tbot/services/k8s/secret_destination.go b/lib/tbot/services/k8s/secret_destination.go index 3bbb58762fd61..808c09bb880b2 100644 --- a/lib/tbot/services/k8s/secret_destination.go +++ b/lib/tbot/services/k8s/secret_destination.go @@ -48,15 +48,20 @@ type SecretDestination struct { // When configured, these labels will overwrite any existing labels on the // secret. Labels map[string]string `yaml:"labels,omitempty"` + // Namespace to write the Kubernetes Secret to. If not specified, it + // defaults to the value of the POD_NAMESPACE environment variable. + // + // When using the Helm chart, you'll need to additionally grant the tbot + // service account permissions to read/write to the other namespace. + Namespace string `yaml:"namespace,omitempty"` mu sync.Mutex - namespace string k8s kubernetes.Interface initialized bool } func (dks *SecretDestination) getSecret(ctx context.Context) (*corev1.Secret, error) { - secret, err := dks.k8s.CoreV1().Secrets(dks.namespace).Get(ctx, dks.Name, v1.GetOptions{}) + secret, err := dks.k8s.CoreV1().Secrets(dks.Namespace).Get(ctx, dks.Name, v1.GetOptions{}) if err != nil { return nil, trace.Wrap(err) } @@ -74,7 +79,7 @@ func (dks *SecretDestination) secretTemplate() *corev1.Secret { Type: corev1.SecretTypeOpaque, ObjectMeta: v1.ObjectMeta{ Name: dks.Name, - Namespace: dks.namespace, + Namespace: dks.Namespace, Labels: dks.Labels, }, Data: map[string][]byte{}, @@ -82,7 +87,7 @@ func (dks *SecretDestination) secretTemplate() *corev1.Secret { } func (dks *SecretDestination) upsertSecret(ctx context.Context, secret *corev1.Secret, dryRun bool) error { - apply := applyconfigv1.Secret(dks.Name, dks.namespace). + apply := applyconfigv1.Secret(dks.Name, dks.Namespace). WithData(secret.Data). WithResourceVersion(secret.ResourceVersion). WithType(secret.Type) @@ -100,7 +105,7 @@ func (dks *SecretDestination) upsertSecret(ctx context.Context, secret *corev1.S applyOpts.DryRun = []string{"All"} } - _, err := dks.k8s.CoreV1().Secrets(dks.namespace).Apply(ctx, apply, applyOpts) + _, err := dks.k8s.CoreV1().Secrets(dks.Namespace).Apply(ctx, apply, applyOpts) if err != nil { return trace.Wrap(err) } @@ -132,9 +137,13 @@ func (dks *SecretDestination) Init(ctx context.Context, subdirs []string) error return trace.BadParameter("destination has already been initialized") } - if dks.namespace == "" { - dks.namespace = os.Getenv(kubernetesNamespaceEnv) - if dks.namespace == "" { + if dks.Namespace == "" { + log.DebugContext( + ctx, + "No explicit namespace provided for Kubernetes secret destination, attempting to detect from environment", + ) + dks.Namespace = os.Getenv(kubernetesNamespaceEnv) + if dks.Namespace == "" { return trace.BadParameter("unable to detect namespace from %s environment variable", kubernetesNamespaceEnv) } } @@ -200,7 +209,7 @@ func (dks *SecretDestination) Write(ctx context.Context, name string, data []byt ctx, "Kubernetes secret missing on attempt to write data. One will be created.", "secret_name", dks.Name, - "secret_namespace", dks.namespace, + "secret_namespace", dks.Namespace, ) // If the secret doesn't exist, we create it on write - this is ensures // that we can recover if the secret is deleted between renewal loops. @@ -238,7 +247,7 @@ func (dks *SecretDestination) WriteMany(ctx context.Context, toWrite map[string] ctx, "Kubernetes secret missing on attempt to write data. One will be created.", "secret_name", dks.Name, - "secret_namespace", dks.namespace, + "secret_namespace", dks.Namespace, ) // If the secret doesn't exist, we create it on write - this is ensures // that we can recover if the secret is deleted between renewal loops. @@ -282,7 +291,12 @@ func (dks *SecretDestination) Read(ctx context.Context, name string) ([]byte, er } func (dks *SecretDestination) String() string { - return fmt.Sprintf("%s: %s", SecretDestinationType, dks.Name) + return fmt.Sprintf( + "%s: %s/%s", + SecretDestinationType, + dks.Namespace, + dks.Name, + ) } func (dks *SecretDestination) MarshalYAML() (any, error) { diff --git a/lib/tbot/services/k8s/secret_destination_test.go b/lib/tbot/services/k8s/secret_destination_test.go index 6eb93d4447a39..f9b01a4eec16b 100644 --- a/lib/tbot/services/k8s/secret_destination_test.go +++ b/lib/tbot/services/k8s/secret_destination_test.go @@ -33,7 +33,8 @@ import ( ) func TestDestinationKubernetesSecret(t *testing.T) { - t.Setenv("POD_NAMESPACE", "test-namespace") + defaultNamespace := "test-namespace" + t.Setenv("POD_NAMESPACE", defaultNamespace) // Hack a reactor into the Kubernetes client-go fake client set as it // doesn't currently support Apply :) @@ -68,8 +69,9 @@ func TestDestinationKubernetesSecret(t *testing.T) { } tests := []struct { - name string - dest *SecretDestination + name string + dest *SecretDestination + wantNamespace string wantErr string }{ @@ -79,6 +81,16 @@ func TestDestinationKubernetesSecret(t *testing.T) { Name: "my-secret", k8s: fakeClientSet(), }, + wantNamespace: defaultNamespace, + }, + { + name: "no existing secret with explicit namespace", + dest: &SecretDestination{ + Name: "my-secret", + Namespace: "my-other-namespace", + k8s: fake.NewClientset(), + }, + wantNamespace: "my-other-namespace", }, { name: "labels", @@ -90,6 +102,7 @@ func TestDestinationKubernetesSecret(t *testing.T) { }, k8s: fakeClientSet(), }, + wantNamespace: defaultNamespace, }, { name: "existing secret", @@ -102,6 +115,7 @@ func TestDestinationKubernetesSecret(t *testing.T) { }, }), }, + wantNamespace: defaultNamespace, }, } for _, tt := range tests { @@ -134,7 +148,9 @@ func TestDestinationKubernetesSecret(t *testing.T) { require.Equal(t, []byte("data-d"), bData) // Check labels have been set - secret, err := tt.dest.k8s.CoreV1().Secrets("test-namespace").Get(ctx, tt.dest.Name, metav1.GetOptions{}) + secret, err := tt.dest.k8s.CoreV1(). + Secrets(tt.wantNamespace). + Get(ctx, tt.dest.Name, metav1.GetOptions{}) require.NoError(t, err) require.Equal(t, tt.dest.Labels, secret.Labels) }) @@ -167,7 +183,11 @@ func TestDestinationKubernetesSecret_YAML(t *testing.T) { { name: "full", in: &SecretDestination{ - Name: "my-secret", + Name: "my-secret", + Namespace: "my-namespace", + Labels: map[string]string{ + "key": "value", + }, }, }, } @@ -175,5 +195,9 @@ func TestDestinationKubernetesSecret_YAML(t *testing.T) { } func TestDestinationKubernetesSecret_String(t *testing.T) { - require.Equal(t, "kubernetes_secret: my-secret", (&SecretDestination{Name: "my-secret"}).String()) + require.Equal( + t, + "kubernetes_secret: foo/my-secret", + (&SecretDestination{Namespace: "foo", Name: "my-secret"}).String(), + ) } diff --git a/lib/tbot/services/k8s/testdata/TestDestinationKubernetesSecret_YAML/full.golden b/lib/tbot/services/k8s/testdata/TestDestinationKubernetesSecret_YAML/full.golden index cbb776b05b068..44ea53f59d695 100644 --- a/lib/tbot/services/k8s/testdata/TestDestinationKubernetesSecret_YAML/full.golden +++ b/lib/tbot/services/k8s/testdata/TestDestinationKubernetesSecret_YAML/full.golden @@ -1,2 +1,5 @@ type: kubernetes_secret name: my-secret +labels: + key: value +namespace: my-namespace