diff --git a/api/v1alpha2/mondooauditconfig_types.go b/api/v1alpha2/mondooauditconfig_types.go index dd16f4095..496bfd3ff 100644 --- a/api/v1alpha2/mondooauditconfig_types.go +++ b/api/v1alpha2/mondooauditconfig_types.go @@ -29,6 +29,11 @@ type MondooAuditConfigSpec struct { Filtering Filtering `json:"filtering,omitempty"` Containers Containers `json:"containers,omitempty"` + // Annotations allows adding custom annotations to all scanned assets. These key-value pairs + // will be attached to every asset discovered by the operator, making them searchable + // and filterable in the Mondoo Console. + Annotations map[string]string `json:"annotations,omitempty"` + // Admission is DEPRECATED and ignored. Admission webhooks were removed in v12.1.0. // The operator will automatically clean up any orphaned admission resources. // See docs/admission-migration-guide.md for migration instructions. diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index dc9a06ba5..eeb853bf5 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -374,6 +374,13 @@ func (in *MondooAuditConfigSpec) DeepCopyInto(out *MondooAuditConfigSpec) { out.ConsoleIntegration = in.ConsoleIntegration in.Filtering.DeepCopyInto(&out.Filtering) in.Containers.DeepCopyInto(&out.Containers) + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.Admission != nil { in, out := &in.Admission, &out.Admission *out = new(DeprecatedAdmission) diff --git a/charts/mondoo-operator/templates/mondooauditconfig-crd.yaml b/charts/mondoo-operator/templates/mondooauditconfig-crd.yaml index c014919f1..214e68ab1 100644 --- a/charts/mondoo-operator/templates/mondooauditconfig-crd.yaml +++ b/charts/mondoo-operator/templates/mondooauditconfig-crd.yaml @@ -101,6 +101,14 @@ spec: during its operation. type: string type: object + annotations: + additionalProperties: + type: string + description: |- + Annotations allows adding custom annotations to all scanned assets. These key-value pairs + will be attached to every asset discovered by the operator, making them searchable + and filterable in the Mondoo Console. + type: object consoleIntegration: properties: enable: diff --git a/cmd/mondoo-operator/resource_watcher/cmd.go b/cmd/mondoo-operator/resource_watcher/cmd.go index ad88fd7b5..fb866059c 100644 --- a/cmd/mondoo-operator/resource_watcher/cmd.go +++ b/cmd/mondoo-operator/resource_watcher/cmd.go @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "go.mondoo.com/mondoo-operator/controllers/resource_watcher" + annot "go.mondoo.com/mondoo-operator/pkg/annotations" "go.mondoo.com/mondoo-operator/pkg/utils/logger" ) @@ -54,6 +55,7 @@ func init() { resourceTypes := Cmd.Flags().StringSlice("resource-types", nil, "Resource types to watch (comma-separated). Overrides --watch-all-resources if specified.") apiProxy := Cmd.Flags().String("api-proxy", "", "HTTP proxy to use for API requests.") timeout := Cmd.Flags().Duration("timeout", 25*time.Minute, "Timeout for scan operations.") + annotations := Cmd.Flags().StringToString("annotation", nil, "Annotations to add to scanned assets (can specify multiple, e.g., --annotation env=prod --annotation team=platform).") Cmd.RunE = func(cmd *cobra.Command, args []string) error { log.SetLogger(logger.NewLogger()) @@ -109,6 +111,11 @@ func init() { } } + // Validate annotations + if err := annot.Validate(*annotations); err != nil { + return fmt.Errorf("invalid annotations: %w", err) + } + logger.Info("Starting resource watcher", "config", *configPath, "namespaces", namespacesList, @@ -117,7 +124,8 @@ func init() { "minimumScanInterval", *minimumScanInterval, "watchAllResources", *watchAllResources, "resourceTypes", resourceTypesList, - "timeout", *timeout) + "timeout", *timeout, + "annotations", *annotations) // Create context with signal handling ctx, cancel := context.WithCancel(context.Background()) @@ -159,9 +167,10 @@ func init() { // Create scanner scanner := resource_watcher.NewScanner(resource_watcher.ScannerConfig{ - ConfigPath: *configPath, - APIProxy: *apiProxy, - Timeout: *timeout, + ConfigPath: *configPath, + APIProxy: *apiProxy, + Timeout: *timeout, + Annotations: *annotations, }) // Create debouncer with rate limiting diff --git a/config/crd/bases/k8s.mondoo.com_mondooauditconfigs.yaml b/config/crd/bases/k8s.mondoo.com_mondooauditconfigs.yaml index 410510f87..adaf36b2c 100644 --- a/config/crd/bases/k8s.mondoo.com_mondooauditconfigs.yaml +++ b/config/crd/bases/k8s.mondoo.com_mondooauditconfigs.yaml @@ -80,6 +80,14 @@ spec: description: ServiceAccountName is DEPRECATED. type: string type: object + annotations: + additionalProperties: + type: string + description: |- + Annotations allows adding custom annotations to all scanned assets. These key-value pairs + will be attached to every asset discovered by the operator, making them searchable + and filterable in the Mondoo Console. + type: object consoleIntegration: properties: enable: diff --git a/controllers/container_image/resources.go b/controllers/container_image/resources.go index ddb954be3..527aed3df 100644 --- a/controllers/container_image/resources.go +++ b/controllers/container_image/resources.go @@ -230,6 +230,13 @@ func Inventory(integrationMRN, clusterUID string, m v1alpha2.MondooAuditConfig, } } + // Add user-defined annotations to all assets + if len(m.Spec.Annotations) > 0 { + for i := range inv.Spec.Assets { + inv.Spec.Assets[i].AddAnnotations(m.Spec.Annotations) + } + } + invBytes, err := yaml.Marshal(inv) if err != nil { return "", err diff --git a/controllers/container_image/resources_test.go b/controllers/container_image/resources_test.go new file mode 100644 index 000000000..825c42226 --- /dev/null +++ b/controllers/container_image/resources_test.go @@ -0,0 +1,42 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package container_image + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.mondoo.com/cnquery/v12/providers-sdk/v1/inventory" + "go.mondoo.com/mondoo-operator/api/v1alpha2" +) + +const testClusterUID = "abcdefg" + +func TestInventory_WithAnnotations(t *testing.T) { + auditConfig := v1alpha2.MondooAuditConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mondoo-client"}, + Spec: v1alpha2.MondooAuditConfigSpec{ + Annotations: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + } + + invStr, err := Inventory("", testClusterUID, auditConfig, v1alpha2.MondooOperatorConfig{}) + require.NoError(t, err, "unexpected error generating inventory") + + var inv inventory.Inventory + require.NoError(t, yaml.Unmarshal([]byte(invStr), &inv)) + require.NotEmpty(t, inv.Spec.Assets, "expected at least one asset") + + for _, asset := range inv.Spec.Assets { + assert.Equal(t, "prod", asset.Annotations["env"], "asset %s missing env annotation", asset.Name) + assert.Equal(t, "platform", asset.Annotations["team"], "asset %s missing team annotation", asset.Name) + } +} diff --git a/controllers/k8s_scan/resources.go b/controllers/k8s_scan/resources.go index b94b7ecd7..413cf7ac4 100644 --- a/controllers/k8s_scan/resources.go +++ b/controllers/k8s_scan/resources.go @@ -876,7 +876,7 @@ func ConfigMap(integrationMRN, clusterUID string, m v1alpha2.MondooAuditConfig, } func ExternalClusterConfigMap(integrationMRN, operatorClusterUID string, cluster v1alpha2.ExternalCluster, m v1alpha2.MondooAuditConfig, cfg v1alpha2.MondooOperatorConfig) (*corev1.ConfigMap, error) { - inv, err := ExternalClusterInventory(integrationMRN, operatorClusterUID, cluster, cfg) + inv, err := ExternalClusterInventory(integrationMRN, operatorClusterUID, cluster, m, cfg) if err != nil { return nil, err } @@ -932,6 +932,13 @@ func Inventory(integrationMRN, clusterUID string, m v1alpha2.MondooAuditConfig, } } + // Add user-defined annotations to all assets + if len(m.Spec.Annotations) > 0 { + for i := range inv.Spec.Assets { + inv.Spec.Assets[i].AddAnnotations(m.Spec.Annotations) + } + } + invBytes, err := yaml.Marshal(inv) if err != nil { return "", err @@ -940,7 +947,7 @@ func Inventory(integrationMRN, clusterUID string, m v1alpha2.MondooAuditConfig, return string(invBytes), nil } -func ExternalClusterInventory(integrationMRN, operatorClusterUID string, cluster v1alpha2.ExternalCluster, cfg v1alpha2.MondooOperatorConfig) (string, error) { +func ExternalClusterInventory(integrationMRN, operatorClusterUID string, cluster v1alpha2.ExternalCluster, m v1alpha2.MondooAuditConfig, cfg v1alpha2.MondooOperatorConfig) (string, error) { // Use cluster-specific filtering if provided, otherwise fall back to empty filtering filtering := cluster.Filtering @@ -994,6 +1001,13 @@ func ExternalClusterInventory(integrationMRN, operatorClusterUID string, cluster } } + // Add user-defined annotations to all assets + if len(m.Spec.Annotations) > 0 { + for i := range inv.Spec.Assets { + inv.Spec.Assets[i].AddAnnotations(m.Spec.Annotations) + } + } + invBytes, err := yaml.Marshal(inv) if err != nil { return "", err diff --git a/controllers/k8s_scan/resources_test.go b/controllers/k8s_scan/resources_test.go new file mode 100644 index 000000000..63ad697e7 --- /dev/null +++ b/controllers/k8s_scan/resources_test.go @@ -0,0 +1,70 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package k8s_scan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.mondoo.com/cnquery/v12/providers-sdk/v1/inventory" + "go.mondoo.com/mondoo-operator/api/v1alpha2" +) + +const testClusterUID = "abcdefg" + +func TestInventory_WithAnnotations(t *testing.T) { + auditConfig := v1alpha2.MondooAuditConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mondoo-client"}, + Spec: v1alpha2.MondooAuditConfigSpec{ + Annotations: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + } + + invStr, err := Inventory("", testClusterUID, auditConfig, v1alpha2.MondooOperatorConfig{}) + require.NoError(t, err, "unexpected error generating inventory") + + var inv inventory.Inventory + require.NoError(t, yaml.Unmarshal([]byte(invStr), &inv)) + require.NotEmpty(t, inv.Spec.Assets, "expected at least one asset") + + for _, asset := range inv.Spec.Assets { + assert.Equal(t, "prod", asset.Annotations["env"], "asset %s missing env annotation", asset.Name) + assert.Equal(t, "platform", asset.Annotations["team"], "asset %s missing team annotation", asset.Name) + } +} + +func TestExternalClusterInventory_WithAnnotations(t *testing.T) { + auditConfig := v1alpha2.MondooAuditConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mondoo-client"}, + Spec: v1alpha2.MondooAuditConfigSpec{ + Annotations: map[string]string{ + "env": "staging", + "team": "security", + }, + }, + } + + cluster := v1alpha2.ExternalCluster{ + Name: "remote-cluster", + } + + invStr, err := ExternalClusterInventory("", testClusterUID, cluster, auditConfig, v1alpha2.MondooOperatorConfig{}) + require.NoError(t, err, "unexpected error generating inventory") + + var inv inventory.Inventory + require.NoError(t, yaml.Unmarshal([]byte(invStr), &inv)) + require.NotEmpty(t, inv.Spec.Assets, "expected at least one asset") + + for _, asset := range inv.Spec.Assets { + assert.Equal(t, "staging", asset.Annotations["env"], "asset %s missing env annotation", asset.Name) + assert.Equal(t, "security", asset.Annotations["team"], "asset %s missing team annotation", asset.Name) + } +} diff --git a/controllers/mondooauditconfig_controller.go b/controllers/mondooauditconfig_controller.go index fee7a1e7d..3aa612749 100644 --- a/controllers/mondooauditconfig_controller.go +++ b/controllers/mondooauditconfig_controller.go @@ -32,6 +32,7 @@ import ( "go.mondoo.com/mondoo-operator/controllers/nodes" resourcewatcher "go.mondoo.com/mondoo-operator/controllers/resource_watcher" "go.mondoo.com/mondoo-operator/controllers/status" + "go.mondoo.com/mondoo-operator/pkg/annotations" "go.mondoo.com/mondoo-operator/pkg/client/mondooclient" "go.mondoo.com/mondoo-operator/pkg/constants" "go.mondoo.com/mondoo-operator/pkg/utils/k8s" @@ -221,6 +222,34 @@ func (r *MondooAuditConfigReconciler) Reconcile(ctx context.Context, req ctrl.Re } }() + // Validate annotations before using them in inventory or CLI args. + // Set a degraded condition so users can see the problem via kubectl describe. + if err := annotations.Validate(mondooAuditConfig.Spec.Annotations); err != nil { + mondooAuditConfig.Status.Conditions = mondoo.SetMondooAuditCondition( + mondooAuditConfig.Status.Conditions, + v1alpha2.MondooOperatorDegraded, + corev1.ConditionTrue, + "InvalidAnnotations", + fmt.Sprintf("Invalid annotations in MondooAuditConfig: %s", err), + mondoo.UpdateConditionIfReasonOrMessageChange, + nil, "", + ) + log.Error(err, "invalid annotations in MondooAuditConfig, skipping reconciliation") + return ctrl.Result{}, nil + } + // Clear any previous annotation validation error + if cond := mondoo.FindMondooAuditConditions(mondooAuditConfig.Status.Conditions, v1alpha2.MondooOperatorDegraded); cond != nil && cond.Reason == "InvalidAnnotations" { + mondooAuditConfig.Status.Conditions = mondoo.SetMondooAuditCondition( + mondooAuditConfig.Status.Conditions, + v1alpha2.MondooOperatorDegraded, + corev1.ConditionFalse, + "AnnotationsValid", + "Annotations are valid", + mondoo.UpdateConditionAlways, + nil, "", + ) + } + // If spec.MondooTokenSecretRef != "" and the Secret referenced in spec.MondooCredsSecretRef // does not exist, then attempt to trade the token for a Mondoo service account and save it // in the Secret referenced in .spec.MondooCredsSecretRef diff --git a/controllers/nodes/resources.go b/controllers/nodes/resources.go index 271a5c03d..cce02a15b 100644 --- a/controllers/nodes/resources.go +++ b/controllers/nodes/resources.go @@ -413,6 +413,13 @@ func Inventory(integrationMRN, clusterUID string, m v1alpha2.MondooAuditConfig) } } + // Add user-defined annotations to all assets + if len(m.Spec.Annotations) > 0 { + for i := range inv.Spec.Assets { + inv.Spec.Assets[i].AddAnnotations(m.Spec.Annotations) + } + } + invBytes, err := yaml.Marshal(inv) if err != nil { return "", err diff --git a/controllers/nodes/resources_test.go b/controllers/nodes/resources_test.go index 92527d2c5..471f5e5ca 100644 --- a/controllers/nodes/resources_test.go +++ b/controllers/nodes/resources_test.go @@ -9,6 +9,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + + "go.mondoo.com/cnquery/v12/providers-sdk/v1/inventory" "go.mondoo.com/mondoo-operator/api/v1alpha2" "go.mondoo.com/mondoo-operator/pkg/constants" "go.mondoo.com/mondoo-operator/pkg/utils/k8s" @@ -242,6 +246,30 @@ func TestInventory(t *testing.T) { assert.Contains(t, inventory, integrationMRN) } +func TestInventory_WithAnnotations(t *testing.T) { + auditConfig := v1alpha2.MondooAuditConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mondoo-client"}, + Spec: v1alpha2.MondooAuditConfigSpec{ + Annotations: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + } + + invStr, err := Inventory("", testClusterUID, auditConfig) + require.NoError(t, err, "unexpected error generating inventory") + + var inv inventory.Inventory + require.NoError(t, yaml.Unmarshal([]byte(invStr), &inv)) + require.NotEmpty(t, inv.Spec.Assets, "expected at least one asset") + + for _, asset := range inv.Spec.Assets { + assert.Equal(t, "prod", asset.Annotations["env"], "asset %s missing env annotation", asset.Name) + assert.Equal(t, "platform", asset.Annotations["team"], "asset %s missing team annotation", asset.Name) + } +} + func testMondooAuditConfig() *v1alpha2.MondooAuditConfig { return &v1alpha2.MondooAuditConfig{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controllers/resource_watcher/resources.go b/controllers/resource_watcher/resources.go index d018fd486..e487d6ae5 100644 --- a/controllers/resource_watcher/resources.go +++ b/controllers/resource_watcher/resources.go @@ -14,6 +14,7 @@ import ( "k8s.io/utils/ptr" "go.mondoo.com/mondoo-operator/api/v1alpha2" + "go.mondoo.com/mondoo-operator/pkg/annotations" "go.mondoo.com/mondoo-operator/pkg/constants" "go.mondoo.com/mondoo-operator/pkg/feature_flags" "go.mondoo.com/mondoo-operator/pkg/utils/k8s" @@ -85,6 +86,9 @@ func Deployment(image string, m *v1alpha2.MondooAuditConfig, cfg v1alpha2.Mondoo cmd = append(cmd, "--api-proxy", *cfg.Spec.HttpProxy) } + // Add annotations (sorted for deterministic ordering) + cmd = append(cmd, annotations.AnnotationArgs(m.Spec.Annotations)...) + envVars := feature_flags.AllFeatureFlagsAsEnv() envVars = append(envVars, corev1.EnvVar{Name: "MONDOO_AUTO_UPDATE", Value: "false"}) diff --git a/controllers/resource_watcher/resources_test.go b/controllers/resource_watcher/resources_test.go index c59878d57..f75ba6e8e 100644 --- a/controllers/resource_watcher/resources_test.go +++ b/controllers/resource_watcher/resources_test.go @@ -234,6 +234,44 @@ func TestDeployment_WatchAllResources(t *testing.T) { assert.Contains(t, cmdStr, "--watch-all-resources") } +func TestDeployment_WithAnnotations(t *testing.T) { + config := &v1alpha2.MondooAuditConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-config", + Namespace: "mondoo-operator", + }, + Spec: v1alpha2.MondooAuditConfigSpec{ + KubernetesResources: v1alpha2.KubernetesResources{ + Enable: true, + ResourceWatcher: v1alpha2.ResourceWatcherSpec{ + Enable: true, + }, + }, + Annotations: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + } + + operatorConfig := v1alpha2.MondooOperatorConfig{} + + deployment := Deployment("ghcr.io/mondoohq/cnspec:latest", config, operatorConfig) + + container := deployment.Spec.Template.Spec.Containers[0] + cmd := container.Command + + // Find --annotation flags and collect their values + annotationArgs := map[string]bool{} + for i, arg := range cmd { + if arg == "--annotation" && i+1 < len(cmd) { + annotationArgs[cmd[i+1]] = true + } + } + assert.True(t, annotationArgs["env=prod"], "expected --annotation env=prod") + assert.True(t, annotationArgs["team=platform"], "expected --annotation team=platform") +} + func TestDeployment_HighPriorityByDefault(t *testing.T) { config := &v1alpha2.MondooAuditConfig{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controllers/resource_watcher/scanner.go b/controllers/resource_watcher/scanner.go index f5035572f..af4aa59cb 100644 --- a/controllers/resource_watcher/scanner.go +++ b/controllers/resource_watcher/scanner.go @@ -11,6 +11,8 @@ import ( "time" ctrl "sigs.k8s.io/controller-runtime" + + "go.mondoo.com/mondoo-operator/pkg/annotations" ) var scannerLogger = ctrl.Log.WithName("resource-watcher-scanner") @@ -23,6 +25,8 @@ type ScannerConfig struct { APIProxy string // Timeout is the timeout for scan operations. Timeout time.Duration + // Annotations are key-value pairs to attach to all scanned assets. + Annotations map[string]string } // Scanner executes cnspec scans on K8s manifests. @@ -78,6 +82,8 @@ func (s *Scanner) ScanManifests(ctx context.Context, manifests []byte) error { if s.config.APIProxy != "" { cnspecArgs = append(cnspecArgs, "--api-proxy", s.config.APIProxy) } + // Add annotations as command-line arguments (sorted for deterministic ordering) + cnspecArgs = append(cnspecArgs, annotations.AnnotationArgs(s.config.Annotations)...) // Create context with timeout scanCtx := ctx diff --git a/pkg/annotations/annotations.go b/pkg/annotations/annotations.go new file mode 100644 index 000000000..4922830a3 --- /dev/null +++ b/pkg/annotations/annotations.go @@ -0,0 +1,57 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package annotations + +import ( + "fmt" + "sort" + "strings" +) + +// AnnotationArgs converts a map of annotations into sorted CLI arguments +// suitable for passing to cnspec via --annotation key=value flags. +func AnnotationArgs(annotations map[string]string) []string { + if len(annotations) == 0 { + return nil + } + + keys := make([]string, 0, len(annotations)) + for k := range annotations { + keys = append(keys, k) + } + sort.Strings(keys) + + args := make([]string, 0, len(annotations)*2) + for _, key := range keys { + args = append(args, "--annotation", fmt.Sprintf("%s=%s", key, annotations[key])) + } + return args +} + +const maxAnnotationLength = 256 + +// Validate checks that annotation keys and values are well-formed for use as +// cnspec --annotation key=value CLI arguments. Keys must be non-empty, must +// not contain '=', and both keys and values must not exceed 256 characters. +// Values must be non-empty. +func Validate(annotations map[string]string) error { + for k, v := range annotations { + if k == "" { + return fmt.Errorf("annotation key must not be empty") + } + if strings.Contains(k, "=") { + return fmt.Errorf("annotation key %q must not contain '='", k) + } + if len(k) > maxAnnotationLength { + return fmt.Errorf("annotation key %q exceeds maximum length of %d characters", k, maxAnnotationLength) + } + if v == "" { + return fmt.Errorf("annotation value for key %q must not be empty", k) + } + if len(v) > maxAnnotationLength { + return fmt.Errorf("annotation value for key %q exceeds maximum length of %d characters", k, maxAnnotationLength) + } + } + return nil +} diff --git a/pkg/annotations/annotations_test.go b/pkg/annotations/annotations_test.go new file mode 100644 index 000000000..c5f666750 --- /dev/null +++ b/pkg/annotations/annotations_test.go @@ -0,0 +1,100 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package annotations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnnotationArgs(t *testing.T) { + t.Run("nil map returns nil", func(t *testing.T) { + assert.Nil(t, AnnotationArgs(nil)) + }) + + t.Run("empty map returns nil", func(t *testing.T) { + assert.Nil(t, AnnotationArgs(map[string]string{})) + }) + + t.Run("single annotation", func(t *testing.T) { + args := AnnotationArgs(map[string]string{"env": "prod"}) + assert.Equal(t, []string{"--annotation", "env=prod"}, args) + }) + + t.Run("multiple annotations are sorted by key", func(t *testing.T) { + args := AnnotationArgs(map[string]string{ + "team": "platform", + "env": "prod", + "app": "mondoo", + }) + assert.Equal(t, []string{ + "--annotation", "app=mondoo", + "--annotation", "env=prod", + "--annotation", "team=platform", + }, args) + }) +} + +func TestValidate(t *testing.T) { + t.Run("valid annotations", func(t *testing.T) { + err := Validate(map[string]string{ + "env": "prod", + "team": "platform", + }) + assert.NoError(t, err) + }) + + t.Run("nil map is valid", func(t *testing.T) { + assert.NoError(t, Validate(nil)) + }) + + t.Run("empty map is valid", func(t *testing.T) { + assert.NoError(t, Validate(map[string]string{})) + }) + + t.Run("empty key is rejected", func(t *testing.T) { + err := Validate(map[string]string{"": "value"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") + }) + + t.Run("key with equals sign is rejected", func(t *testing.T) { + err := Validate(map[string]string{"key=bad": "value"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain '='") + }) + + t.Run("empty value is rejected", func(t *testing.T) { + err := Validate(map[string]string{"key": ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") + }) + + t.Run("key at max length is valid", func(t *testing.T) { + longKey := strings.Repeat("k", 256) + assert.NoError(t, Validate(map[string]string{longKey: "value"})) + }) + + t.Run("key exceeding max length is rejected", func(t *testing.T) { + longKey := strings.Repeat("k", 257) + err := Validate(map[string]string{longKey: "value"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum length of 256") + }) + + t.Run("value at max length is valid", func(t *testing.T) { + longVal := strings.Repeat("v", 256) + assert.NoError(t, Validate(map[string]string{"key": longVal})) + }) + + t.Run("value exceeding max length is rejected", func(t *testing.T) { + longVal := strings.Repeat("v", 257) + err := Validate(map[string]string{"key": longVal}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum length of 256") + }) +}