Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
#
ARG FIPS_ENABLED=false
ARG SGC_VERSION=7.84.0
ARG SGC_TAG_SUFFIX

FROM datadog/secret-generic-connector:${SGC_VERSION}${SGC_TAG_SUFFIX} AS sgc

# Build the manager binary
FROM golang:1.26.7 AS builder
Expand Down Expand Up @@ -57,6 +61,7 @@ COPY --from=certs /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/certs/ca-bundle.crt

WORKDIR /
COPY --from=builder /workspace/manager .
COPY --from=sgc --chmod=755 /secret-generic-connector /usr/local/bin/secret-generic-connector

COPY --from=builder --chmod=550 /workspace/helpers .
COPY --chmod=550 scripts/readsecret.sh .
Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ PLATFORM=$(shell uname -s | tr '[:upper:]' '[:lower:]')-$(shell uname -m)
ROOT=$(dir $(abspath $(firstword $(MAKEFILE_LIST))))
KUSTOMIZE_CONFIG?=config/default
FIPS_ENABLED?=false
SGC_TAG_SUFFIX?=$(if $(filter true,$(FIPS_ENABLED)),-fips,)

# Default bundle image tag
BUNDLE_IMG ?= controller-bundle:$(VERSION)
Expand Down Expand Up @@ -173,7 +174,7 @@ docker-build: generate docker-build-ci docker-build-check-ci
# For local use
.PHONY: docker-build-ci
docker-build-ci:
docker build . -t ${IMG} --build-arg FIPS_ENABLED="${FIPS_ENABLED}" --build-arg LDFLAGS="${LDFLAGS}" --build-arg GOARCH="${GOARCH}"
docker build . -t ${IMG} --build-arg FIPS_ENABLED="${FIPS_ENABLED}" --build-arg SGC_TAG_SUFFIX="${SGC_TAG_SUFFIX}" --build-arg LDFLAGS="${LDFLAGS}" --build-arg GOARCH="${GOARCH}"

# For local use
.PHONY: docker-build-check-ci
Expand All @@ -184,7 +185,7 @@ docker-build-check-ci:
# For Gitlab use
.PHONY: docker-build-push-ci
docker-build-push-ci:
docker buildx build . -t ${IMG} --build-arg FIPS_ENABLED="${FIPS_ENABLED}" --build-arg LDFLAGS="${LDFLAGS}" --build-arg GOARCH="${GOARCH}" --platform=linux/${GOARCH} --output=type=image,oci-mediatypes=true --push
docker buildx build . -t ${IMG} --build-arg FIPS_ENABLED="${FIPS_ENABLED}" --build-arg SGC_TAG_SUFFIX="${SGC_TAG_SUFFIX}" --build-arg LDFLAGS="${LDFLAGS}" --build-arg GOARCH="${GOARCH}" --platform=linux/${GOARCH} --output=type=image,oci-mediatypes=true --push

# For Gitlab use
.PHONY: docker-build-push-check-ci
Expand Down
40 changes: 35 additions & 5 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
Expand Down Expand Up @@ -144,6 +145,8 @@ type options struct {
// Secret Backend options
secretBackendCommand string
secretBackendArgs stringSlice
secretBackendType string
secretBackendConfig string
secretRefreshInterval time.Duration
}

Expand All @@ -165,6 +168,8 @@ func (opts *options) Parse() {
// Custom flags
flag.StringVar(&opts.secretBackendCommand, "secretBackendCommand", "", "Secret backend command")
flag.Var(&opts.secretBackendArgs, "secretBackendArgs", "Space separated arguments of the secret backend command")
flag.StringVar(&opts.secretBackendType, "secretBackendType", "", "Secret backend type for the embedded secret-generic-connector")
flag.StringVar(&opts.secretBackendConfig, "secretBackendConfig", "", "JSON object of secret backend config for the secret-generic-connector")
flag.DurationVar(&opts.secretRefreshInterval, "secretRefreshInterval", 0, "Interval for refreshing secrets from secret backend")
flag.BoolVar(&opts.supportCilium, "supportCilium", false, "Support usage of Cilium network policies.")
flag.BoolVar(&opts.datadogAgentEnabled, "datadogAgentEnabled", true, "Enable the DatadogAgent controller")
Expand Down Expand Up @@ -333,8 +338,9 @@ func run(opts *options) error {
}

// Dispatch CLI flags to each package
secrets.SetSecretBackendCommand(opts.secretBackendCommand)
secrets.SetSecretBackendArgs(opts.secretBackendArgs)
if err := configureSecretBackend(opts); err != nil {
setupLog.Error(err, "Invalid -secretBackendConfig JSON, ignoring")
}

renewDeadline := opts.leaderElectionLeaseDuration / 2
retryPeriod := opts.leaderElectionLeaseDuration / 4
Expand Down Expand Up @@ -429,9 +435,10 @@ func run(opts *options) error {
setupLog.Error(err, "Unable to get credentials")
}

if opts.secretRefreshInterval > 0 && opts.secretBackendCommand == "" {
setupLog.Error(nil, "secretRefreshInterval is set but secretBackendCommand is not configured")
} else if opts.secretBackendCommand != "" && opts.secretRefreshInterval > 0 {
secretBackendConfigured := opts.secretBackendCommand != "" || opts.secretBackendType != ""
if opts.secretRefreshInterval > 0 && !secretBackendConfigured {
setupLog.Error(nil, "secretRefreshInterval is set but no secret backend is configured")
} else if secretBackendConfigured && opts.secretRefreshInterval > 0 {
go credsManager.StartCredentialRefreshRoutine(opts.secretRefreshInterval, setupLog)
}

Expand Down Expand Up @@ -553,6 +560,29 @@ func run(opts *options) error {
return nil
}

func parseSecretBackendConfig(raw string) (map[string]any, error) {
var config map[string]any
err := json.Unmarshal([]byte(raw), &config)
return config, err
}

func configureSecretBackend(opts *options) error {
backendConfig := map[string]any{}
if opts.secretBackendConfig != "" {
var err error
backendConfig, err = parseSecretBackendConfig(opts.secretBackendConfig)
if err != nil {
return err
}
}

secrets.SetSecretBackendCommand(opts.secretBackendCommand)
secrets.SetSecretBackendArgs(opts.secretBackendArgs)
secrets.SetSecretBackendType(opts.secretBackendType)
secrets.SetSecretBackendConfig(backendConfig)
return nil
}

func getVersionAndPlatformInfo(configCopy *rest.Config) (*apimversion.Info, kubernetes.PlatformInfo, error) {
// Never use original mgr.GetConfig(), always copy as clients might modify the configuration
discoveryClient, err := discovery.NewDiscoveryClientForConfig(configCopy)
Expand Down
53 changes: 53 additions & 0 deletions cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"time"

"github.com/DataDog/datadog-operator/pkg/fleet"
"github.com/DataDog/datadog-operator/pkg/secrets"
"github.com/go-logr/zapr"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
Expand Down Expand Up @@ -148,6 +149,58 @@ func TestOptionsParse_CLIOverridesEnv(t *testing.T) {
require.False(t, opts.defaultDataPlaneLinuxEnabled)
}

func TestOptionsParse_SecretBackendSGC(t *testing.T) {
resetCommandLine(t,
"-secretBackendType=hashicorp.vault",
`-secretBackendConfig={"vault_session":{"vault_auth_type":"kubernetes"}}`,
)

var opts options
opts.Parse()

require.Equal(t, "hashicorp.vault", opts.secretBackendType)
require.JSONEq(t, `{"vault_session":{"vault_auth_type":"kubernetes"}}`, opts.secretBackendConfig)
}

func TestParseSecretBackendConfig(t *testing.T) {
config, err := parseSecretBackendConfig(`{"vault_session":{"vault_auth_type":"kubernetes"}}`)
require.NoError(t, err)
require.Equal(t, map[string]any{
"vault_session": map[string]any{"vault_auth_type": "kubernetes"},
}, config)

_, err = parseSecretBackendConfig("not-json")
require.Error(t, err)
}

func TestConfigureSecretBackend(t *testing.T) {
resetSecretBackend := func() {
secrets.SetSecretBackendCommand("")
secrets.SetSecretBackendArgs(nil)
secrets.SetSecretBackendType("")
secrets.SetSecretBackendConfig(map[string]any{})
}
resetSecretBackend()
t.Cleanup(resetSecretBackend)

require.NoError(t, configureSecretBackend(&options{}))
require.NoError(t, configureSecretBackend(&options{
secretBackendType: "hashicorp.vault",
secretBackendConfig: `{"vault_session":{"vault_auth_type":"kubernetes"}}`,
}))

resetSecretBackend()
require.Error(t, configureSecretBackend(&options{secretBackendConfig: "not-json"}))

resetSecretBackend()
require.Error(t, configureSecretBackend(&options{
secretBackendType: "hashicorp.vault",
secretBackendConfig: "not-json",
}))
_, err := secrets.NewSecretBackend().Decrypt([]string{"ENC[vault://test]"})
require.EqualError(t, err, "secret backend command not configured")
}

func TestOptionsParse_InvalidEnvLeavesDefault(t *testing.T) {
resetCommandLine(t)
t.Setenv("DD_MAXIMUM_GOROUTINES", "not-an-int")
Expand Down
68 changes: 59 additions & 9 deletions pkg/secrets/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,22 @@ import (
var (
secretBackendCommand = ""
secretBackendArgs = []string{}
secretBackendType = ""
secretBackendConfig = map[string]any{}
)

const (
defaultCmdOutputMaxSize = 1024 * 1024
defaultCmdTimeout = 5 * time.Second
defaultCmdTimeout = 30 * time.Second
Comment thread
s-alad marked this conversation as resolved.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

changed to 30s since both the regular datadog-agent and sgc default to 30s


// PayloadVersion represents the version of the SB API
PayloadVersion = "1.0"
// sgcPayloadVersion is the API version sent when resolving via the embedded secret-generic-connector,
// whose payload additionally carries the backend "type" and "config".
sgcPayloadVersion = "1.1"

// defaultSGCBinaryPath is the embedded secret-generic-connector binary shipped in the operator image.
defaultSGCBinaryPath = "/usr/local/bin/secret-generic-connector"
)

// SetSecretBackendCommand set the secretBackendCommand var
Expand All @@ -45,11 +53,41 @@ func SetSecretBackendArgs(args []string) {
secretBackendArgs = args
}

// SetSecretBackendType sets the secret backend type used by the embedded
// secret-generic-connector (e.g. "hashicorp.vault"). When set with an empty
// secret backend command, secrets are resolved via the embedded SGC binary.
func SetSecretBackendType(backendType string) {
secretBackendType = backendType
}

// SetSecretBackendConfig sets the secret backend config passed to the
// secret-generic-connector in the resolution payload.
func SetSecretBackendConfig(config map[string]any) {
secretBackendConfig = config
}

// NewSecretBackend returns a new SecretBackend instance
func NewSecretBackend() *SecretBackend {
cmd := secretBackendCommand
cmdArgs := secretBackendArgs
backendType := secretBackendType
backendConfig := secretBackendConfig

if cmd != "" {
// An explicit command uses the legacy secret-backend protocol.
backendType = ""
backendConfig = nil
} else if backendType != "" {
// A backend type without a command uses the embedded SGC binary.
cmd = defaultSGCBinaryPath
cmdArgs = nil
}

return &SecretBackend{
cmd: secretBackendCommand,
cmdArgs: secretBackendArgs,
cmd: cmd,
cmdArgs: cmdArgs,
backendType: backendType,
backendConfig: backendConfig,
cmdOutputMaxSize: defaultCmdOutputMaxSize,
cmdTimeout: defaultCmdTimeout,
}
Expand All @@ -64,19 +102,31 @@ func (sb *SecretBackend) Decrypt(encrypted []string) (map[string]string, error)
return sb.fetchSecret(encrypted)
}

// buildPayload assembles the JSON payload sent to the secret backend binary.
func (sb *SecretBackend) buildPayload(handles []string) map[string]any {
if sb.backendType != "" {
return map[string]any{
"version": sgcPayloadVersion,
"secrets": handles,
"type": sb.backendType,
"config": sb.backendConfig,
"secret_backend_timeout": sb.cmdTimeout.Seconds(),
}
}
return map[string]any{
"version": PayloadVersion,
"secrets": handles,
}
}

// fetchSecret tries to get secrets by executing the secret backend command
func (sb *SecretBackend) fetchSecret(encrypted []string) (map[string]string, error) {
handles, err := extractHandles(encrypted)
if err != nil {
return nil, NewDecryptorError(err, false)
}

payload := map[string]any{
"version": PayloadVersion,
"secrets": handles,
}

jsonPayload, err := json.Marshal(payload)
jsonPayload, err := json.Marshal(sb.buildPayload(handles))
if err != nil {
return nil, NewDecryptorError(err, false)
}
Expand Down
81 changes: 81 additions & 0 deletions pkg/secrets/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package secrets
import (
"reflect"
"testing"
"time"
)

func TestSecretBackend_execCommand(t *testing.T) {
Expand Down Expand Up @@ -123,6 +124,86 @@ func TestSecretBackend_Decrypt(t *testing.T) {
}
}

func TestSecretBackend_buildPayload(t *testing.T) {
handles := []string{"api_key", "app_key"}

// Classic command path: only version + secrets, no SGC fields.
legacy := (&SecretBackend{}).buildPayload(handles)
if legacy["version"] != PayloadVersion {
t.Errorf("legacy version = %v, want %v", legacy["version"], PayloadVersion)
}
for _, k := range []string{"type", "config", "secret_backend_timeout"} {
if _, ok := legacy[k]; ok {
t.Errorf("legacy payload must not include %q", k)
}
}

// SGC path: version 1.1 plus type, config and timeout.
sb := &SecretBackend{
backendType: "hashicorp.vault",
backendConfig: map[string]any{"vault_session": map[string]any{"vault_auth_type": "kubernetes"}},
cmdTimeout: 30 * time.Second,
}
sgc := sb.buildPayload(handles)
if sgc["version"] != sgcPayloadVersion {
t.Errorf("sgc version = %v, want %v", sgc["version"], sgcPayloadVersion)
}
if sgc["type"] != "hashicorp.vault" {
t.Errorf("sgc type = %v, want hashicorp.vault", sgc["type"])
}
if sgc["secret_backend_timeout"] != float64(30) {
t.Errorf("sgc secret_backend_timeout = %v, want 30", sgc["secret_backend_timeout"])
}
if !reflect.DeepEqual(sgc["config"], sb.backendConfig) {
t.Errorf("sgc config = %v, want %v", sgc["config"], sb.backendConfig)
}
}

func TestNewSecretBackend_embeddedSGC(t *testing.T) {
defer func() {
SetSecretBackendCommand("")
SetSecretBackendArgs([]string{})
SetSecretBackendType("")
SetSecretBackendConfig(map[string]any{})
}()

// Backend type set without an explicit command -> use the embedded SGC binary.
SetSecretBackendCommand("")
SetSecretBackendArgs([]string{"--legacy-arg"})
SetSecretBackendType("hashicorp.vault")
SetSecretBackendConfig(map[string]any{"vault_session": map[string]any{"vault_auth_type": "kubernetes"}})
sb := NewSecretBackend()
if sb.cmd != defaultSGCBinaryPath {
t.Errorf("cmd = %q, want embedded SGC path %q", sb.cmd, defaultSGCBinaryPath)
}
if len(sb.cmdArgs) != 0 {
t.Errorf("embedded SGC args = %v, want none", sb.cmdArgs)
}
// The timeout is sent to SGC as secret_backend_timeout, so it must not be shorter
// than SGC's own 30s default
if sb.cmdTimeout < 30*time.Second {
t.Errorf("cmdTimeout = %v, want >= 30s for SGC operations", sb.cmdTimeout)
}

// An explicit command always wins over the embedded default.
SetSecretBackendCommand("/custom/secret-backend")
sb = NewSecretBackend()
if sb.cmd != "/custom/secret-backend" {
t.Errorf("cmd = %q, want /custom/secret-backend", sb.cmd)
}
if !reflect.DeepEqual(sb.cmdArgs, secretBackendArgs) {
t.Errorf("explicit command args = %v, want %v", sb.cmdArgs, secretBackendArgs)
}
if sb.backendType != "" || sb.backendConfig != nil {
t.Errorf("explicit command must use the legacy payload; got type %q and config %v", sb.backendType, sb.backendConfig)
}
for _, key := range []string{"type", "config", "secret_backend_timeout"} {
if _, found := sb.buildPayload([]string{"api_key"})[key]; found {
t.Errorf("explicit command payload must not include %q", key)
}
}
}

func TestSecretBackend_fetchSecret(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading