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
3 changes: 1 addition & 2 deletions .github/agents/cnpg-i-plugin-developer-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ func (impl Implementation) GetCapabilities(
Kind: "Pod",
OperationTypes: []*lifecycle.OperatorOperationType{
{Type: lifecycle.OperatorOperationType_TYPE_CREATE},
{Type: lifecycle.OperatorOperationType_TYPE_PATCH},
},
},
},
Expand Down Expand Up @@ -380,7 +379,7 @@ When developing or reviewing CNPG-I plugin code:

2. **Incorrect JSON patch direction** — `object.CreatePatch(mutated, original)` takes mutated first, original second. Swapping them produces an inverse patch.

3. **Not handling all operation types** — If your lifecycle hook registers for `TYPE_CREATE` and `TYPE_PATCH`, make sure your `LifecycleHook` implementation handles both (or returns an empty response for unhandled operations).
3. **Mutating Pod spec during PATCH**: Pod containers, volumes, and environment variables are immutable after creation. Sidecar injectors should register for `TYPE_CREATE` only. Register `TYPE_PATCH` only when the hook is restricted to mutable fields.

4. **Plugin name mismatch** — The plugin name in metadata, the Kubernetes Service label `cnpg.io/pluginName`, and the Cluster spec `plugins[].name` must all match exactly.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ spec:
resource:
storage:
pvcSize: 5Gi
sidecarInjectorPluginName: cnpg-i-sidecar-injector.documentdb.io
plugins:
sidecarInjectorName: cnpg-i-sidecar-injector.documentdb.io
# Enable the OTel Collector sidecar (one per pod). The sidecar:
# - receives OTLP metrics from the documentdb-gateway on localhost:4317
# - exposes Prometheus /metrics on the configured port
Expand Down
4 changes: 2 additions & 2 deletions documentdb-playground/telemetry/local/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ kubectl create configmap grafana-dashboards \
# Step 5: Deploy DocumentDB
# spec.monitoring.enabled triggers the operator to create an OTel ConfigMap
# and inject the otel-collector sidecar via the CNPG sidecar-injector plugin.
# The CNPG-managed <cluster>-app secret is reused for the sidecar's PG creds —
# no dedicated monitoring role is needed.
# The operator provisions the dedicated passwordless otel_monitor role used by
# the sidecar's local PostgreSQL connection.
echo "[5/6] Deploying DocumentDB..."
kubectl apply -f "$LOCAL_DIR/k8s/documentdb/" --context "$CONTEXT"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
documentDbCredentialSecretParameter = "documentDbCredentialSecret"
otelCollectorImageParameter = "otelCollectorImage"
otelConfigMapNameParameter = "otelConfigMapName"
otelConfigHashParameter = "otelConfigHash"
otelMemoryRequestParameter = "otelMemoryRequest"
otelMemoryLimitParameter = "otelMemoryLimit"
otelCPURequestParameter = "otelCpuRequest"
Expand All @@ -47,6 +48,7 @@ type Configuration struct {
DocumentDbCredentialSecret string
OtelCollectorImage string
OtelConfigMapName string
OtelConfigHash string
OTelMemoryRequest string
OTelMemoryLimit string
OTelCPURequest string
Expand Down Expand Up @@ -84,6 +86,8 @@ func FromParameters(
gatewayImage := helper.Parameters[gatewayImageParameter]
credentialSecret := helper.Parameters[documentDbCredentialSecretParameter]
pullPolicy := parsePullPolicy(helper.Parameters[gatewayImagePullPolicyParameter])
otelCollectorImage := helper.Parameters[otelCollectorImageParameter]
otelConfigMapName := helper.Parameters[otelConfigMapNameParameter]
validateQuantityParameters(helper, &validationErrors,
gatewayMemoryRequestParameter,
gatewayMemoryLimitParameter,
Expand All @@ -108,6 +112,37 @@ func FromParameters(
}
}

requiredOtelParameters := []string{
otelCollectorImageParameter,
otelConfigMapNameParameter,
}
otelParameters := append([]string{
prometheusPortParameter,
otelConfigHashParameter,
otelMemoryRequestParameter,
otelMemoryLimitParameter,
otelCPURequestParameter,
otelCPULimitParameter,
}, requiredOtelParameters...)
otelConfigured := false
for _, parameter := range otelParameters {
otelConfigured = otelConfigured || helper.Parameters[parameter] != ""
}
if otelConfigured {
for _, parameter := range requiredOtelParameters {
if helper.Parameters[parameter] == "" {
validationErrors = append(
validationErrors,
validation.BuildErrorForParameter(
helper,
parameter,
"required when any OTel sidecar parameter is configured",
),
)
}
}
}

configuration := &Configuration{
Labels: labels,
Annotations: annotations,
Expand All @@ -118,8 +153,9 @@ func FromParameters(
GatewayCPURequest: helper.Parameters[gatewayCPURequestParameter],
GatewayCPULimit: helper.Parameters[gatewayCPULimitParameter],
DocumentDbCredentialSecret: credentialSecret,
OtelCollectorImage: helper.Parameters[otelCollectorImageParameter],
OtelConfigMapName: helper.Parameters[otelConfigMapNameParameter],
OtelCollectorImage: otelCollectorImage,
OtelConfigMapName: otelConfigMapName,
OtelConfigHash: helper.Parameters[otelConfigHashParameter],
OTelMemoryRequest: helper.Parameters[otelMemoryRequestParameter],
OTelMemoryLimit: helper.Parameters[otelMemoryLimitParameter],
OTelCPURequest: helper.Parameters[otelCPURequestParameter],
Expand Down Expand Up @@ -231,10 +267,16 @@ func (config *Configuration) ToParameters() (map[string]string, error) {
setIfNotEmpty(gatewayCPURequestParameter, config.GatewayCPURequest)
setIfNotEmpty(gatewayCPULimitParameter, config.GatewayCPULimit)
result[documentDbCredentialSecretParameter] = config.DocumentDbCredentialSecret
setIfNotEmpty(otelCollectorImageParameter, config.OtelCollectorImage)
setIfNotEmpty(otelConfigMapNameParameter, config.OtelConfigMapName)
setIfNotEmpty(otelConfigHashParameter, config.OtelConfigHash)
setIfNotEmpty(otelMemoryRequestParameter, config.OTelMemoryRequest)
setIfNotEmpty(otelMemoryLimitParameter, config.OTelMemoryLimit)
setIfNotEmpty(otelCPURequestParameter, config.OTelCPURequest)
setIfNotEmpty(otelCPULimitParameter, config.OTelCPULimit)
if config.PrometheusPort > 0 {
result[prometheusPortParameter] = strconv.FormatInt(int64(config.PrometheusPort), 10)
}

return result, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,105 @@ func TestFromParameters(t *testing.T) {
}
})

t.Run("parses OTel monitoring parameters", func(t *testing.T) {
helper := &common.Plugin{Parameters: map[string]string{
"otelCollectorImage": "otel/opentelemetry-collector-contrib:test",
"otelConfigMapName": "demo-otel-config",
"otelConfigHash": "abc123",
}}
config, errs := FromParameters(helper)
if len(errs) != 0 {
t.Fatalf("unexpected validation errors: %v", errs)
}
if config.OtelCollectorImage != "otel/opentelemetry-collector-contrib:test" {
t.Errorf("OtelCollectorImage = %q", config.OtelCollectorImage)
}
if config.OtelConfigMapName != "demo-otel-config" {
t.Errorf("OtelConfigMapName = %q", config.OtelConfigMapName)
}
if config.OtelConfigHash != "abc123" {
t.Errorf("OtelConfigHash = %q", config.OtelConfigHash)
}
})

for _, tt := range []struct {
name string
parameters map[string]string
wantErrors int
}{
{
name: "rejects collector image without config map",
parameters: map[string]string{
"otelCollectorImage": "otel/opentelemetry-collector-contrib:test",
},
wantErrors: 1,
},
{
name: "rejects config map without collector image",
parameters: map[string]string{
"otelConfigMapName": "demo-otel-config",
},
wantErrors: 1,
},
{
name: "rejects optional OTel parameter without required parameters",
parameters: map[string]string{
"prometheusPort": "8888",
},
wantErrors: 2,
},
{
name: "rejects config hash without required parameters",
parameters: map[string]string{
"otelConfigHash": "abc123",
},
wantErrors: 2,
},
{
name: "rejects memory request without required parameters",
parameters: map[string]string{
"otelMemoryRequest": "64Mi",
},
wantErrors: 2,
},
{
name: "rejects memory limit without required parameters",
parameters: map[string]string{
"otelMemoryLimit": "128Mi",
},
wantErrors: 2,
},
{
name: "rejects CPU request without required parameters",
parameters: map[string]string{
"otelCpuRequest": "100m",
},
wantErrors: 2,
},
{
name: "rejects CPU limit without required parameters",
parameters: map[string]string{
"otelCpuLimit": "300m",
},
wantErrors: 2,
},
} {
t.Run(tt.name, func(t *testing.T) {
_, errs := FromParameters(&common.Plugin{Parameters: tt.parameters})
if len(errs) != tt.wantErrors {
t.Fatalf("validation errors = %d, want %d: %v", len(errs), tt.wantErrors, errs)
}
})
}

t.Run("resource parameters from parameters", func(t *testing.T) {
helper := &common.Plugin{Parameters: map[string]string{
"gatewayMemoryRequest": "768Mi",
"gatewayMemoryLimit": "3Gi",
"gatewayCpuRequest": "500m",
"gatewayCpuLimit": "2",
"otelCollectorImage": "otel:latest",
"otelConfigMapName": "otel-config",
"otelMemoryRequest": "64Mi",
"otelMemoryLimit": "128Mi",
"otelCpuRequest": "100m",
Expand Down Expand Up @@ -130,6 +223,9 @@ func TestToParametersRoundTrip(t *testing.T) {
GatewayMemoryLimit: "3Gi",
GatewayCPURequest: "500m",
GatewayCPULimit: "2",
OtelCollectorImage: "otel:latest",
OtelConfigMapName: "otel-config",
OtelConfigHash: "abc123",
OTelMemoryRequest: "64Mi",
OTelMemoryLimit: "128Mi",
OTelCPURequest: "100m",
Expand Down Expand Up @@ -164,6 +260,15 @@ func TestToParametersRoundTrip(t *testing.T) {
if restored.GatewayCPULimit != original.GatewayCPULimit {
t.Errorf("round-trip gateway cpu limit = %q, want %q", restored.GatewayCPULimit, original.GatewayCPULimit)
}
if restored.OtelCollectorImage != original.OtelCollectorImage {
t.Errorf("round-trip OTel collector image = %q, want %q", restored.OtelCollectorImage, original.OtelCollectorImage)
}
if restored.OtelConfigMapName != original.OtelConfigMapName {
t.Errorf("round-trip OTel config map = %q, want %q", restored.OtelConfigMapName, original.OtelConfigMapName)
}
if restored.OtelConfigHash != original.OtelConfigHash {
t.Errorf("round-trip OTel config hash = %q, want %q", restored.OtelConfigHash, original.OtelConfigHash)
}
if restored.OTelMemoryRequest != original.OTelMemoryRequest {
t.Errorf("round-trip otel memory request = %q, want %q", restored.OTelMemoryRequest, original.OTelMemoryRequest)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,13 @@ func (impl Implementation) GetCapabilities(
{
Type: lifecycle.OperatorOperationType_TYPE_CREATE,
},
{
Type: lifecycle.OperatorOperationType_TYPE_PATCH,
},
},
},
},
}, nil
}

// LifecycleHook is called by CNPG for Pod CREATE/PATCH/UPDATE operations
// LifecycleHook is called by CNPG for Pod CREATE operations.
func (impl Implementation) LifecycleHook(
ctx context.Context,
request *lifecycle.OperatorLifecycleRequest,
Expand All @@ -71,8 +68,7 @@ func (impl Implementation) LifecycleHook(
switch kind {
case "Pod":
switch *operation {
case lifecycle.OperatorOperationType_TYPE_CREATE, lifecycle.OperatorOperationType_TYPE_PATCH,
lifecycle.OperatorOperationType_TYPE_UPDATE:
case lifecycle.OperatorOperationType_TYPE_CREATE:
return impl.reconcileMetadata(ctx, request)
}
// add any other custom logic to execute based on the operation
Expand Down Expand Up @@ -249,7 +245,7 @@ func (impl Implementation) reconcileMetadata(
log.Printf("Injecting OTel Collector sidecar with image: %s", configuration.OtelCollectorImage)

// Add ConfigMap volume for operator-generated config files (static.yaml + dynamic.yaml)
// Check for existing volume to be idempotent across CREATE and PATCH operations
// Check for an existing volume so pod construction remains idempotent.
otelVolFound := false
for _, v := range mutatedPod.Spec.Volumes {
if v.Name == "otel-config" {
Expand All @@ -270,7 +266,7 @@ func (impl Implementation) reconcileMetadata(
})
}

otelSidecar := newOtelCollectorSidecar(configuration.OtelCollectorImage, cluster.Name)
otelSidecar := newOtelCollectorSidecar(configuration.OtelCollectorImage)
if resources := buildResources(
configuration.OTelCPURequest,
configuration.OTelCPULimit,
Expand Down Expand Up @@ -463,11 +459,9 @@ func gatewayOTelEnvVars() []corev1.EnvVar {
// container, idempotently. Existing env vars with the same name are preserved
// (we don't overwrite) and missing ones are appended in declaration order.
//
// Idempotency matters: this hook fires on both CREATE and PATCH operations.
// Without name-based dedup, repeated reconciles would double-append env
// entries and CNPG's pod metadata reconciler would fail with
// "Pod is invalid: spec: Forbidden: pod updates may not change fields other
// than ...".
// Idempotency keeps pod construction stable if a caller passes an already
// mutated pod. Container injection itself runs only for CREATE because
// Kubernetes forbids adding or removing containers from an existing pod.
func injectGatewayOTelEnv(pod *corev1.Pod) {
envs := gatewayOTelEnvVars()
for i := range pod.Spec.Containers {
Expand All @@ -490,6 +484,7 @@ func injectGatewayOTelEnv(pod *corev1.Pod) {
// otelCollectorContainerName is the name of the injected OpenTelemetry
// Collector sidecar.
const otelCollectorContainerName = "otel-collector"
const otelMonitorRoleName = "otel_monitor"

// gatewaySecurityContext returns the SecurityContext for the documentdb-gateway
// sidecar: the shared PSA-restricted hardening plus an explicit UID/GID of
Expand All @@ -506,19 +501,18 @@ func gatewaySecurityContext() *corev1.SecurityContext {
// the caller). It carries the shared PSA-restricted SecurityContext without an
// explicit UID so the upstream collector image keeps its own baked-in non-root
// user (UID 10001); PSA "restricted" only requires runAsNonRoot, not a fixed
// UID. clusterName selects the CNPG-managed "<cluster>-app" credential secret.
func newOtelCollectorSidecar(image, clusterName string) *corev1.Container {
// UID.
func newOtelCollectorSidecar(image string) *corev1.Container {
return &corev1.Container{
Name: otelCollectorContainerName,
Image: image,
Args: []string{
"--config=file:/config/static.yaml",
"--config=file:/config/dynamic.yaml",
},
// PGUSER and PGPASSWORD are sourced from the CNPG-managed application secret
// ("<cluster>-app"). CNPG auto-creates this secret with "username" and "password"
// keys for the application database user. The OTel Collector's sqlquery receiver
// uses these credentials to connect to PostgreSQL and collect health metrics.
// PostgreSQL currently uses trust authentication, so injecting a password
// would not enforce access control. Use the dedicated password-disabled
// identity directly until database authentication is tightened.
Env: []corev1.EnvVar{
{
Name: "POD_NAME",
Expand All @@ -529,26 +523,8 @@ func newOtelCollectorSidecar(image, clusterName string) *corev1.Container {
},
},
{
Name: "PGUSER",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: clusterName + "-app",
},
Key: "username",
},
},
},
{
Name: "PGPASSWORD",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: clusterName + "-app",
},
Key: "password",
},
},
Name: "PGUSER",
Value: otelMonitorRoleName,
},
},
VolumeMounts: []corev1.VolumeMount{
Expand Down
Loading
Loading