diff --git a/pkg/validate/testdata/valid_datadogagent.yaml b/pkg/validate/testdata/valid_datadogagent.yaml new file mode 100644 index 0000000000..2430922afb --- /dev/null +++ b/pkg/validate/testdata/valid_datadogagent.yaml @@ -0,0 +1,18 @@ +apiVersion: datadoghq.com/v2alpha1 +kind: DatadogAgent +metadata: + name: datadog-demo +spec: + global: + clusterName: demo-cluster + credentials: + apiKey: "0123456789abcdef0123456789abcdef" + appKey: "0123456789abcdef0123456789abcdef01234567" + commonLabels: + team: platform + environment: staging + features: + apm: + enabled: true + logCollection: + enabled: true diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go new file mode 100644 index 0000000000..c55130e195 --- /dev/null +++ b/pkg/validate/validate.go @@ -0,0 +1,50 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +// Package validate offers a reusable entry point for parsing a DatadogAgent +// manifest and running the operator's own semantic validation against it, +// without needing a running cluster or reconcile loop. +package validate + +import ( + "fmt" + "os" + + "sigs.k8s.io/yaml" + + datadoghqv2alpha1 "github.com/DataDog/datadog-operator/api/datadoghq/v2alpha1" +) + +// ParseAndValidateBytes strictly decodes a DatadogAgent YAML/JSON document and +// runs the operator's semantic validation (the same ValidateDatadogAgent check +// the reconcile loop performs). Strict decoding rejects unknown/duplicate +// fields, so structural mistakes surface here rather than being silently +// dropped. The parsed DatadogAgent is returned so callers can inspect it. +func ParseAndValidateBytes(data []byte) (*datadoghqv2alpha1.DatadogAgent, error) { + dda := &datadoghqv2alpha1.DatadogAgent{} + if err := yaml.UnmarshalStrict(data, dda); err != nil { + return nil, fmt.Errorf("parsing DatadogAgent: %w", err) + } + + if dda.Name == "" { + return nil, fmt.Errorf("DatadogAgent has no metadata.name") + } + + if err := datadoghqv2alpha1.ValidateDatadogAgent(dda); err != nil { + return dda, fmt.Errorf("invalid DatadogAgent %q: %w", dda.Name, err) + } + + return dda, nil +} + +// ParseAndValidateFile reads a DatadogAgent manifest from disk and delegates to +// ParseAndValidateBytes. +func ParseAndValidateFile(path string) (*datadoghqv2alpha1.DatadogAgent, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + return ParseAndValidateBytes(data) +} diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go new file mode 100644 index 0000000000..12a5153350 --- /dev/null +++ b/pkg/validate/validate_test.go @@ -0,0 +1,134 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package validate + +import ( + "strings" + "testing" +) + +func TestParseAndValidateBytes(t *testing.T) { + tests := []struct { + name string + manifest string + wantErr bool + errContains string + }{ + { + name: "valid manifest", + manifest: `apiVersion: datadoghq.com/v2alpha1 +kind: DatadogAgent +metadata: + name: datadog-demo +spec: + global: + credentials: + apiKey: key + appKey: appkey + commonLabels: + team: platform +`, + wantErr: false, + }, + { + name: "missing name", + manifest: `apiVersion: datadoghq.com/v2alpha1 +kind: DatadogAgent +spec: + global: + credentials: + apiKey: key +`, + wantErr: true, + errContains: "no metadata.name", + }, + { + name: "missing credentials fails semantic validation", + manifest: `apiVersion: datadoghq.com/v2alpha1 +kind: DatadogAgent +metadata: + name: datadog-demo +spec: + global: + clusterName: demo +`, + wantErr: true, + errContains: "credentials not configured", + }, + { + name: "reserved commonLabels prefix rejected", + manifest: `apiVersion: datadoghq.com/v2alpha1 +kind: DatadogAgent +metadata: + name: datadog-demo +spec: + global: + credentials: + apiKey: key + commonLabels: + agent.datadoghq.com/foo: bar +`, + wantErr: true, + errContains: "reserved key", + }, + { + name: "unknown field rejected by strict decoding", + manifest: `apiVersion: datadoghq.com/v2alpha1 +kind: DatadogAgent +metadata: + name: datadog-demo +spec: + global: + credentials: + apiKey: key + thisFieldDoesNotExist: true +`, + wantErr: true, + errContains: "parsing DatadogAgent", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dda, err := ParseAndValidateBytes([]byte(tt.manifest)) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dda == nil { + t.Fatalf("expected non-nil DatadogAgent") + } + if dda.Name != "datadog-demo" { + t.Fatalf("expected name datadog-demo, got %q", dda.Name) + } + }) + } +} + +func TestParseAndValidateFile(t *testing.T) { + _, err := ParseAndValidateFile("testdata/valid_datadogagent.yaml") + if err != nil { + t.Fatalf("unexpected error validating demo manifest: %v", err) + } +} + +func TestParseAndValidateFile_NotFound(t *testing.T) { + _, err := ParseAndValidateFile("testdata/does_not_exist.yaml") + if err == nil { + t.Fatalf("expected error for missing file") + } + if !strings.Contains(err.Error(), "reading") { + t.Fatalf("expected reading error, got %q", err.Error()) + } +}