From 00d221a49a561821974baecea603300b2ad9b7e0 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Thu, 5 Feb 2026 10:18:55 +0100 Subject: [PATCH 1/2] Add name validation to valkey and opensearch This ensures the name is allowed as a k8s resource name --- internal/persistence/opensearch/models.go | 4 + .../persistence/opensearch/models_test.go | 175 ++++++++++++++++++ internal/persistence/valkey/models.go | 4 + internal/persistence/valkey/models_test.go | 175 ++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 internal/persistence/opensearch/models_test.go create mode 100644 internal/persistence/valkey/models_test.go diff --git a/internal/persistence/opensearch/models.go b/internal/persistence/opensearch/models.go index 64c88af3b..b4b1a70cd 100644 --- a/internal/persistence/opensearch/models.go +++ b/internal/persistence/opensearch/models.go @@ -21,6 +21,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" ) type ( @@ -227,6 +228,9 @@ func (o *OpenSearchMetadataInput) ValidationErrors(ctx context.Context) *validat if o.Name == "" { verr.Add("name", "Name must not be empty.") } + if errs := validation.IsDNS1123Subdomain(o.Name); len(errs) > 0 { + verr.Add("name", "Name must be a valid DNS name: lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") + } if o.EnvironmentName == "" { verr.Add("environmentName", "Environment name must not be empty.") } diff --git a/internal/persistence/opensearch/models_test.go b/internal/persistence/opensearch/models_test.go new file mode 100644 index 000000000..de0fccbe6 --- /dev/null +++ b/internal/persistence/opensearch/models_test.go @@ -0,0 +1,175 @@ +package opensearch_test + +import ( + "context" + "strings" + "testing" + + "github.com/nais/api/internal/persistence/opensearch" + "github.com/nais/api/internal/slug" +) + +func TestOpenSearchMetadataInput_Validate(t *testing.T) { + dnsError := "Name must be a valid DNS name: lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen." + + tests := []struct { + name string + input opensearch.OpenSearchMetadataInput + wantErr string + }{ + { + name: "valid input", + input: opensearch.OpenSearchMetadataInput{ + Name: "my-opensearch", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + { + name: "valid input with trimmed whitespace", + input: opensearch.OpenSearchMetadataInput{ + Name: " my-opensearch ", + EnvironmentName: " dev ", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + { + name: "empty name", + input: opensearch.OpenSearchMetadataInput{ + Name: "", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: Name must not be empty.\nname: " + dnsError, + }, + { + name: "whitespace only name", + input: opensearch.OpenSearchMetadataInput{ + Name: " ", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: Name must not be empty.\nname: " + dnsError, + }, + { + name: "invalid DNS name - uppercase", + input: opensearch.OpenSearchMetadataInput{ + Name: "MyOpenSearch", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - starts with hyphen", + input: opensearch.OpenSearchMetadataInput{ + Name: "-my-opensearch", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - ends with hyphen", + input: opensearch.OpenSearchMetadataInput{ + Name: "my-opensearch-", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - contains underscore", + input: opensearch.OpenSearchMetadataInput{ + Name: "my_opensearch", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - too long (exceeds 253 chars)", + input: opensearch.OpenSearchMetadataInput{ + Name: strings.Repeat("a", 254), + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "empty environment name", + input: opensearch.OpenSearchMetadataInput{ + Name: "my-opensearch", + EnvironmentName: "", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "environmentName: Environment name must not be empty.", + }, + { + name: "whitespace only environment name", + input: opensearch.OpenSearchMetadataInput{ + Name: "my-opensearch", + EnvironmentName: " ", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "environmentName: Environment name must not be empty.", + }, + { + name: "empty team slug", + input: opensearch.OpenSearchMetadataInput{ + Name: "my-opensearch", + EnvironmentName: "dev", + TeamSlug: slug.Slug(""), + }, + wantErr: "teamSlug: Team slug must not be empty.", + }, + { + name: "all fields empty", + input: opensearch.OpenSearchMetadataInput{ + Name: "", + EnvironmentName: "", + TeamSlug: slug.Slug(""), + }, + wantErr: "name: Name must not be empty.\nname: " + dnsError + "\nenvironmentName: Environment name must not be empty.\nteamSlug: Team slug must not be empty.", + }, + { + name: "valid DNS name with numbers", + input: opensearch.OpenSearchMetadataInput{ + Name: "opensearch-123", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + { + name: "valid DNS name - max length (63 chars)", + input: opensearch.OpenSearchMetadataInput{ + Name: strings.Repeat("a", 63), + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.Validate(context.Background()) + if tt.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Error("expected error but got nil") + return + } + if err.Error() != tt.wantErr { + t.Errorf("error mismatch\ngot: %q\nwant: %q", err.Error(), tt.wantErr) + } + } + }) + } +} diff --git a/internal/persistence/valkey/models.go b/internal/persistence/valkey/models.go index 438fbbd31..bc1e19c44 100644 --- a/internal/persistence/valkey/models.go +++ b/internal/persistence/valkey/models.go @@ -19,6 +19,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" ) type ( @@ -216,6 +217,9 @@ func (v *ValkeyMetadataInput) ValidationErrors(ctx context.Context) *validate.Va if v.Name == "" { verr.Add("name", "Name must not be empty.") } + if errs := validation.IsDNS1123Subdomain(v.Name); len(errs) > 0 { + verr.Add("name", "Name must consist of lowercase letters, numbers, hyphens, or periods, and must start and end with a letter or number.") + } if v.EnvironmentName == "" { verr.Add("environmentName", "Environment name must not be empty.") } diff --git a/internal/persistence/valkey/models_test.go b/internal/persistence/valkey/models_test.go new file mode 100644 index 000000000..d0caf25cd --- /dev/null +++ b/internal/persistence/valkey/models_test.go @@ -0,0 +1,175 @@ +package valkey_test + +import ( + "context" + "strings" + "testing" + + "github.com/nais/api/internal/persistence/valkey" + "github.com/nais/api/internal/slug" +) + +func TestValkeyMetadataInput_Validate(t *testing.T) { + dnsError := "Name must consist of lowercase letters, numbers, hyphens, or periods, and must start and end with a letter or number." + + tests := []struct { + name string + input valkey.ValkeyMetadataInput + wantErr string + }{ + { + name: "valid input", + input: valkey.ValkeyMetadataInput{ + Name: "my-valkey", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + { + name: "valid input with trimmed whitespace", + input: valkey.ValkeyMetadataInput{ + Name: " my-valkey ", + EnvironmentName: " dev ", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + { + name: "empty name", + input: valkey.ValkeyMetadataInput{ + Name: "", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: Name must not be empty.\nname: " + dnsError, + }, + { + name: "whitespace only name", + input: valkey.ValkeyMetadataInput{ + Name: " ", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: Name must not be empty.\nname: " + dnsError, + }, + { + name: "invalid DNS name - uppercase", + input: valkey.ValkeyMetadataInput{ + Name: "My-Valkey", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - starts with hyphen", + input: valkey.ValkeyMetadataInput{ + Name: "-my-valkey", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - ends with hyphen", + input: valkey.ValkeyMetadataInput{ + Name: "my-valkey-", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - contains underscore", + input: valkey.ValkeyMetadataInput{ + Name: "my_valkey", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "invalid DNS name - too long", + input: valkey.ValkeyMetadataInput{ + Name: strings.Repeat("a", 254), + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "name: " + dnsError, + }, + { + name: "empty environment name", + input: valkey.ValkeyMetadataInput{ + Name: "my-valkey", + EnvironmentName: "", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "environmentName: Environment name must not be empty.", + }, + { + name: "whitespace only environment name", + input: valkey.ValkeyMetadataInput{ + Name: "my-valkey", + EnvironmentName: " ", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "environmentName: Environment name must not be empty.", + }, + { + name: "empty team slug", + input: valkey.ValkeyMetadataInput{ + Name: "my-valkey", + EnvironmentName: "dev", + TeamSlug: slug.Slug(""), + }, + wantErr: "teamSlug: Team slug must not be empty.", + }, + { + name: "multiple validation errors", + input: valkey.ValkeyMetadataInput{ + Name: "", + EnvironmentName: "", + TeamSlug: slug.Slug(""), + }, + wantErr: "name: Name must not be empty.\nname: " + dnsError + "\nenvironmentName: Environment name must not be empty.\nteamSlug: Team slug must not be empty.", + }, + { + name: "valid DNS name with numbers", + input: valkey.ValkeyMetadataInput{ + Name: "valkey-123", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + { + name: "valid DNS name starting with number", + input: valkey.ValkeyMetadataInput{ + Name: "123-valkey", + EnvironmentName: "dev", + TeamSlug: slug.Slug("my-team"), + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.Validate(context.Background()) + if tt.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Error("expected error but got nil") + return + } + if err.Error() != tt.wantErr { + t.Errorf("error mismatch\ngot: %q\nwant: %q", err.Error(), tt.wantErr) + } + } + }) + } +} From 067d80565ecac9893ee6d7801dc8c85f414028a6 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Thu, 5 Feb 2026 10:37:08 +0100 Subject: [PATCH 2/2] Rephrase validation error Co-Authored-By: Trong Huu Nguyen --- internal/persistence/opensearch/models.go | 2 +- internal/persistence/opensearch/models_test.go | 2 +- internal/persistence/valkey/models.go | 2 +- internal/persistence/valkey/models_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/persistence/opensearch/models.go b/internal/persistence/opensearch/models.go index b4b1a70cd..f0cad5c55 100644 --- a/internal/persistence/opensearch/models.go +++ b/internal/persistence/opensearch/models.go @@ -229,7 +229,7 @@ func (o *OpenSearchMetadataInput) ValidationErrors(ctx context.Context) *validat verr.Add("name", "Name must not be empty.") } if errs := validation.IsDNS1123Subdomain(o.Name); len(errs) > 0 { - verr.Add("name", "Name must be a valid DNS name: lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") + verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") } if o.EnvironmentName == "" { verr.Add("environmentName", "Environment name must not be empty.") diff --git a/internal/persistence/opensearch/models_test.go b/internal/persistence/opensearch/models_test.go index de0fccbe6..41dc8cf74 100644 --- a/internal/persistence/opensearch/models_test.go +++ b/internal/persistence/opensearch/models_test.go @@ -10,7 +10,7 @@ import ( ) func TestOpenSearchMetadataInput_Validate(t *testing.T) { - dnsError := "Name must be a valid DNS name: lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen." + dnsError := "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen." tests := []struct { name string diff --git a/internal/persistence/valkey/models.go b/internal/persistence/valkey/models.go index bc1e19c44..a6c8736d8 100644 --- a/internal/persistence/valkey/models.go +++ b/internal/persistence/valkey/models.go @@ -218,7 +218,7 @@ func (v *ValkeyMetadataInput) ValidationErrors(ctx context.Context) *validate.Va verr.Add("name", "Name must not be empty.") } if errs := validation.IsDNS1123Subdomain(v.Name); len(errs) > 0 { - verr.Add("name", "Name must consist of lowercase letters, numbers, hyphens, or periods, and must start and end with a letter or number.") + verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") } if v.EnvironmentName == "" { verr.Add("environmentName", "Environment name must not be empty.") diff --git a/internal/persistence/valkey/models_test.go b/internal/persistence/valkey/models_test.go index d0caf25cd..2ea2f5c59 100644 --- a/internal/persistence/valkey/models_test.go +++ b/internal/persistence/valkey/models_test.go @@ -10,7 +10,7 @@ import ( ) func TestValkeyMetadataInput_Validate(t *testing.T) { - dnsError := "Name must consist of lowercase letters, numbers, hyphens, or periods, and must start and end with a letter or number." + dnsError := "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen." tests := []struct { name string