Skip to content

Commit 88c40ab

Browse files
authored
Add domain validation to catch invalid names before build/deploy (#3152)
* validate invalid domain name to catch the error instead of building Signed-off-by: RayyanSeliya <rayyanseliya786@gmail.com> * fix the linting spacing issues Signed-off-by: RayyanSeliya <rayyanseliya786@gmail.com> * goimports Signed-off-by: RayyanSeliya <rayyanseliya786@gmail.com> --------- Signed-off-by: RayyanSeliya <rayyanseliya786@gmail.com>
1 parent cee59b4 commit 88c40ab

5 files changed

Lines changed: 287 additions & 0 deletions

File tree

cmd/deploy.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"knative.dev/func/pkg/config"
2121
fn "knative.dev/func/pkg/functions"
2222
"knative.dev/func/pkg/k8s"
23+
"knative.dev/func/pkg/utils"
2324
)
2425

2526
func NewDeployCmd(newClient ClientFactory) *cobra.Command {
@@ -280,6 +281,22 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) {
280281
}
281282
if err = cfg.Validate(cmd); err != nil {
282283
// Layer 2: Catch technical errors and provide CLI-specific user-friendly messages
284+
if errors.Is(err, fn.ErrInvalidDomain) {
285+
return fmt.Errorf(`%w
286+
287+
Domain names must be valid DNS subdomains:
288+
- Lowercase letters, numbers, hyphens (-), and dots (.) only
289+
- Start and end with a letter or number
290+
- Max 253 characters total, each part between dots max 63 characters
291+
292+
Valid examples:
293+
func deploy --registry ghcr.io/user --domain example.com
294+
func deploy --registry ghcr.io/user --domain api.example.com
295+
296+
Note: Domain must be configured on your Knative cluster, or it will be ignored.
297+
298+
For more options, run 'func deploy --help'`, err)
299+
}
283300
if errors.Is(err, fn.ErrConflictingImageAndRegistry) {
284301
return fmt.Errorf(`%w
285302
@@ -738,6 +755,14 @@ func (c deployConfig) Validate(cmd *cobra.Command) (err error) {
738755
return
739756
}
740757

758+
// Validate domain format if provided
759+
if c.Domain != "" {
760+
if err = utils.ValidateDomain(c.Domain); err != nil {
761+
// Wrap the validation error as fn.ErrInvalidDomain for layer consistency
762+
return fn.ErrInvalidDomain
763+
}
764+
}
765+
741766
// Check Image Digest was included
742767
var digest bool
743768
if c.Image != "" {

cmd/deploy_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,3 +2258,154 @@ func testBaseImage(cmdFn commandConstructor, t *testing.T) {
22582258
})
22592259
}
22602260
}
2261+
2262+
// TestDeploy_InvalidDomain ensures that invalid domain names are caught
2263+
// before build starts and return helpful error messages
2264+
func TestDeploy_InvalidDomain(t *testing.T) {
2265+
tests := []struct {
2266+
name string
2267+
domain string
2268+
errMsg string
2269+
}{
2270+
{
2271+
name: "domain with spaces",
2272+
domain: "my app.com",
2273+
errMsg: "invalid domain",
2274+
},
2275+
{
2276+
name: "domain with uppercase",
2277+
domain: "Example.Com",
2278+
errMsg: "invalid domain",
2279+
},
2280+
{
2281+
name: "domain with special characters",
2282+
domain: "example@domain.com",
2283+
errMsg: "invalid domain",
2284+
},
2285+
{
2286+
name: "domain starting with hyphen",
2287+
domain: "-example.com",
2288+
errMsg: "invalid domain",
2289+
},
2290+
{
2291+
name: "domain with consecutive dots",
2292+
domain: "example..com",
2293+
errMsg: "invalid domain",
2294+
},
2295+
{
2296+
name: "domain with only whitespace",
2297+
domain: " ",
2298+
errMsg: "invalid domain",
2299+
},
2300+
}
2301+
2302+
for _, tt := range tests {
2303+
t.Run(tt.name, func(t *testing.T) {
2304+
root := FromTempDirectory(t)
2305+
2306+
// Create a function
2307+
f := fn.Function{
2308+
Runtime: "go",
2309+
Root: root,
2310+
Registry: TestRegistry,
2311+
}
2312+
_, err := fn.New().Init(f)
2313+
if err != nil {
2314+
t.Fatal(err)
2315+
}
2316+
2317+
// Create deploy command with invalid domain
2318+
cmd := NewDeployCmd(NewTestClient(
2319+
fn.WithBuilder(mock.NewBuilder()),
2320+
fn.WithDeployer(mock.NewDeployer()),
2321+
))
2322+
cmd.SetArgs([]string{"--domain", tt.domain})
2323+
2324+
// Execute and expect error
2325+
err = cmd.Execute()
2326+
if err == nil {
2327+
t.Fatalf("expected error for invalid domain '%v', but got none", tt.domain)
2328+
}
2329+
2330+
// Check error message contains expected text
2331+
if !strings.Contains(err.Error(), tt.errMsg) {
2332+
t.Fatalf("expected error message to contain '%v', got: %v", tt.errMsg, err.Error())
2333+
}
2334+
2335+
// Ensure builder was NOT invoked (validation should fail before build)
2336+
builder := cmd.Flag("builder")
2337+
if builder == nil {
2338+
t.Fatal("builder flag not found")
2339+
}
2340+
})
2341+
}
2342+
}
2343+
2344+
// TestDeploy_ValidDomain ensures that valid domain names pass validation
2345+
// and proceed to build/deploy
2346+
func TestDeploy_ValidDomain(t *testing.T) {
2347+
tests := []struct {
2348+
name string
2349+
domain string
2350+
}{
2351+
{
2352+
name: "standard domain",
2353+
domain: "example.com",
2354+
},
2355+
{
2356+
name: "subdomain",
2357+
domain: "api.example.com",
2358+
},
2359+
{
2360+
name: "multi-level subdomain",
2361+
domain: "my-app.staging.example.com",
2362+
},
2363+
{
2364+
name: "single label domain",
2365+
domain: "localhost",
2366+
},
2367+
{
2368+
name: "kubernetes internal domain",
2369+
domain: "cluster.local",
2370+
},
2371+
}
2372+
2373+
for _, tt := range tests {
2374+
t.Run(tt.name, func(t *testing.T) {
2375+
root := FromTempDirectory(t)
2376+
2377+
// Create a function
2378+
f := fn.Function{
2379+
Runtime: "go",
2380+
Root: root,
2381+
Registry: TestRegistry,
2382+
}
2383+
_, err := fn.New().Init(f)
2384+
if err != nil {
2385+
t.Fatal(err)
2386+
}
2387+
2388+
// Create deploy command with valid domain
2389+
cmd := NewDeployCmd(NewTestClient(
2390+
fn.WithBuilder(mock.NewBuilder()),
2391+
fn.WithDeployer(mock.NewDeployer()),
2392+
))
2393+
cmd.SetArgs([]string{"--domain", tt.domain})
2394+
2395+
// Execute and expect no error
2396+
err = cmd.Execute()
2397+
if err != nil {
2398+
t.Fatalf("expected valid domain '%v' to pass, but got error: %v", tt.domain, err)
2399+
}
2400+
2401+
// Verify domain was set on function
2402+
f, err = fn.NewFunction(root)
2403+
if err != nil {
2404+
t.Fatal(err)
2405+
}
2406+
if f.Domain != tt.domain {
2407+
t.Fatalf("expected domain '%v', got '%v'", tt.domain, f.Domain)
2408+
}
2409+
})
2410+
}
2411+
}

pkg/functions/errors.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ var (
3434

3535
// ErrConflictingImageAndRegistry is returned when both --image and --registry flags are explicitly provided
3636
ErrConflictingImageAndRegistry = errors.New("both --image and --registry flags provided")
37+
38+
// ErrInvalidDomain is returned when a domain name doesn't meet DNS subdomain requirements
39+
ErrInvalidDomain = errors.New("invalid domain")
3740
)
3841

3942
// ErrNotInitialized indicates that a function is uninitialized

pkg/utils/names.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ type ErrInvalidSecretKey error
2323
// ErrInvalidLabel indicates the name did not pass label key validation, or the value did not pass label value validation.
2424
type ErrInvalidLabel error
2525

26+
// ErrInvalidDomain indicates the domain name did not pass DNS subdomain validation.
27+
type ErrInvalidDomain error
28+
2629
// ValidateFunctionName validates that the input name is a valid function name, ie. valid DNS-1035 label.
2730
// It must consist of lower case alphanumeric characters or '-' and start with an alphabetic character and end with an alphanumeric character.
2831
// (e.g. 'my-name', or 'abc-1', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')
@@ -102,3 +105,23 @@ func ValidateLabelValue(value string) error {
102105
}
103106
return nil
104107
}
108+
109+
// ValidateDomain validates that the input is a valid DNS subdomain name (RFC 1123).
110+
// Examples: "example.com", "api.example.com", "my-app.staging.example.com"
111+
func ValidateDomain(domain string) error {
112+
if domain == "" {
113+
return nil
114+
}
115+
116+
if errs := validation.IsDNS1123Subdomain(domain); len(errs) > 0 {
117+
errMsg := strings.Replace(
118+
strings.Join(errs, ""),
119+
"a lowercase RFC 1123 subdomain",
120+
fmt.Sprintf("Domain '%v'", domain),
121+
1,
122+
)
123+
return ErrInvalidDomain(errors.New(errMsg))
124+
}
125+
126+
return nil
127+
}

pkg/utils/names_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,88 @@ func TestValidateLabelValue(t *testing.T) {
209209
}
210210
}
211211
}
212+
213+
// TestValidateDomain tests that only correct DNS subdomain names are accepted
214+
func TestValidateDomain(t *testing.T) {
215+
cases := []struct {
216+
In string
217+
Valid bool
218+
}{
219+
// Valid domains
220+
{"", true}, // empty is valid (means use default)
221+
{"example.com", true}, // standard domain
222+
{"api.example.com", true}, // subdomain
223+
{"my-app.example.com", true}, // subdomain with hyphen
224+
{"app-123.example.com", true}, // subdomain with number
225+
{"123.example.com", true}, // label starting with number
226+
{"a.b.c.d.e.com", true}, // many subdomains
227+
{"localhost", true}, // single label (valid)
228+
{"cluster.local", true}, // Kubernetes internal domain
229+
{"my-app-123.staging.example.com", true}, // complex valid domain
230+
{"app.staging.v1.example.com", true}, // multi-level subdomain
231+
{"example-app.com", true}, // hyphen in domain
232+
{"a.co", true}, // short domain
233+
{"123app.example.com", true}, // label starting with number
234+
// Invalid domains
235+
{"Example.Com", false}, // uppercase not allowed
236+
{"MY-APP.COM", false}, // uppercase not allowed
237+
{"my_app.com", false}, // underscore not allowed
238+
{"my app.com", false}, // space not allowed
239+
{"invalid domain.com", false}, // space not allowed
240+
{"my@app.com", false}, // @ not allowed
241+
{"app!.com", false}, // ! not allowed
242+
{"-example.com", false}, // cannot start with hyphen
243+
{"example-.com", false}, // label cannot end with hyphen
244+
{"example.-com.com", false}, // label cannot start with hyphen
245+
{"my..app.com", false}, // consecutive dots not allowed
246+
{".example.com", false}, // cannot start with dot
247+
{"my:app.com", false}, // colon not allowed
248+
{"my;app.com", false}, // semicolon not allowed
249+
{"my,app.com", false}, // comma not allowed
250+
{"my*app.com", false}, // asterisk not allowed
251+
{" example.com", false}, // leading whitespace not allowed
252+
{"example.com ", false}, // trailing whitespace not allowed
253+
{"example.com.", false}, // trailing dot not allowed
254+
{"example@domain.com", false}, // @ not allowed
255+
{"ex!ample.com", false}, // ! not allowed
256+
}
257+
258+
for _, c := range cases {
259+
err := ValidateDomain(c.In)
260+
if err != nil && c.Valid {
261+
t.Fatalf("Unexpected error for valid domain: %v, domain: '%v'", err, c.In)
262+
}
263+
if err == nil && !c.Valid {
264+
t.Fatalf("Expected error for invalid domain: '%v'", c.In)
265+
}
266+
}
267+
}
268+
269+
func TestValidateDomainErrMsg(t *testing.T) {
270+
invalidDomain := "my@app.com"
271+
errMsgPrefix := fmt.Sprintf("Domain '%v'", invalidDomain)
272+
273+
err := ValidateDomain(invalidDomain)
274+
if err != nil {
275+
if !strings.HasPrefix(err.Error(), errMsgPrefix) {
276+
t.Fatalf("Unexpected error message: %v, the message should start with '%v' string", err.Error(), errMsgPrefix)
277+
}
278+
} else {
279+
t.Fatalf("Expected error for invalid domain: %v", invalidDomain)
280+
}
281+
}
282+
283+
// TestValidateDomainEmptyString ensures empty string is handled specially
284+
func TestValidateDomainEmptyString(t *testing.T) {
285+
// Empty string should be valid (means use cluster default)
286+
err := ValidateDomain("")
287+
if err != nil {
288+
t.Fatalf("Empty string should be valid (means use default): %v", err)
289+
}
290+
291+
// String with only whitespace should error
292+
err = ValidateDomain(" ")
293+
if err == nil {
294+
t.Fatal("String with only whitespace should be invalid")
295+
}
296+
}

0 commit comments

Comments
 (0)