diff --git a/apps/cli/src/linter/specs/route.spec.ts b/apps/cli/src/linter/specs/route.spec.ts index fa794f50..95d05119 100644 --- a/apps/cli/src/linter/specs/route.spec.ts +++ b/apps/cli/src/linter/specs/route.spec.ts @@ -31,6 +31,42 @@ describe('Route Linter', () => { expect: true, errors: [], }, + ...[ + { + name: 'should accept a stream route with only sni', + route: { name: 'test', sni: 'a.example.com' }, + expect: true, + }, + { + name: 'should accept a stream route with only snis', + route: { name: 'test', snis: ['a.example.com'] }, + expect: true, + }, + { + name: 'should reject a stream route with both sni and snis', + route: { + name: 'test', + sni: 'a.example.com', + snis: ['b.example.com'], + }, + expect: false, + }, + ].map((c) => ({ + name: c.name, + input: { + services: [{ name: 'test', stream_routes: [c.route] }], + } as ADCSDK.Configuration, + expect: c.expect, + errors: c.expect + ? [] + : [ + { + code: 'custom', + message: 'Stream route must not specify both sni and snis', + path: ['services', 0, 'stream_routes', 0], + }, + ], + })), ]; // test cases runner diff --git a/libs/sdk/src/core/schema.ts b/libs/sdk/src/core/schema.ts index 0f2bbde7..8c4b0c27 100644 --- a/libs/sdk/src/core/schema.ts +++ b/libs/sdk/src/core/schema.ts @@ -236,25 +236,29 @@ const routeSchema = z.strictObject({ export type Route = z.infer; export { routeSchema }; -const streamRouteSchema = z.strictObject({ - id: idSchema.optional(), - name: nameSchema, - description: descriptionSchema.optional(), - labels: labelsSchema.optional(), +const streamRouteSchema = z + .strictObject({ + id: idSchema.optional(), + name: nameSchema, + description: descriptionSchema.optional(), + labels: labelsSchema.optional(), - plugins: withDifferMeta(pluginsSchema.optional(), { - listType: FieldListType.OBJECT_MAP, - }), + plugins: withDifferMeta(pluginsSchema.optional(), { + listType: FieldListType.OBJECT_MAP, + }), - remote_addr: z.string().optional(), - server_addr: z.string().optional(), - server_port: portSchema.optional(), - // `sni` and `snis` are singular/plural forms of the same match, and the - // gateway rejects a stream route carrying both. - sni: hostSchema.optional(), - snis: z.array(hostSchema).min(1).optional(), - tls_passthrough: z.boolean().optional(), -}); + remote_addr: z.string().optional(), + server_addr: z.string().optional(), + server_port: portSchema.optional(), + // `sni` and `snis` are singular/plural forms of the same match, and the + // gateway rejects a stream route carrying both. + sni: hostSchema.optional(), + snis: z.array(hostSchema).min(1).optional(), + tls_passthrough: z.boolean().optional(), + }) + .refine((val) => isNil(val.sni) || isNil(val.snis), { + error: 'Stream route must not specify both sni and snis', + }); export type StreamRoute = z.infer; export { streamRouteSchema }; diff --git a/rust/crates/adc-sdk/src/lint.rs b/rust/crates/adc-sdk/src/lint.rs index cae87f36..065b7124 100644 --- a/rust/crates/adc-sdk/src/lint.rs +++ b/rust/crates/adc-sdk/src/lint.rs @@ -22,7 +22,7 @@ use std::sync::LazyLock; -use crate::resources::{Configuration, Consumer, Service, Upstream}; +use crate::resources::{Configuration, Consumer, Service, StreamRoute, Upstream}; use crate::value_diff::{DiffPath, PathSegment, format_path}; static SCHEMA_VALIDATOR: LazyLock = LazyLock::new(|| { @@ -161,6 +161,30 @@ fn check_service(service: &Service, path: &[PathSegment], issues: &mut Vec) { + if route.sni.is_some() && route.snis.is_some() { + issues.push(LintIssue { + path: path.to_vec(), + message: "stream route must not specify both \"sni\" and \"snis\"".to_string(), + }); + } } /// `nodes` and service discovery (`discovery_type`+`service_name`) are @@ -326,6 +350,62 @@ mod tests { assert_eq!(lint(&config), Vec::new()); } + fn service_with_stream_route(sni: Option<&str>, snis: Option<&[&str]>) -> Service { + Service { + routes: Some(ServiceRoutes::Stream { + stream_routes: vec![StreamRoute { + id: None, + name: "sr".into(), + description: None, + labels: None, + plugins: None, + remote_addr: None, + server_addr: None, + server_port: None, + sni: sni.map(Into::into), + snis: snis.map(|s| s.iter().map(|h| h.to_string()).collect()), + tls_passthrough: None, + }], + }), + ..minimal_service() + } + } + + #[test] + fn stream_route_with_both_sni_and_snis_is_rejected() { + let config = Configuration { + services: Some(vec![service_with_stream_route( + Some("a.example.com"), + Some(&["b.example.com"]), + )]), + ..empty_config() + }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert_eq!(format_path(&issues[0].path), "services[0].stream_routes[0]"); + } + + #[test] + fn stream_route_with_only_sni_is_accepted() { + let config = Configuration { + services: Some(vec![service_with_stream_route(Some("a.example.com"), None)]), + ..empty_config() + }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn stream_route_with_only_snis_is_accepted() { + let config = Configuration { + services: Some(vec![service_with_stream_route( + None, + Some(&["a.example.com"]), + )]), + ..empty_config() + }; + assert_eq!(lint(&config), Vec::new()); + } + #[test] fn path_prefix_without_a_leading_slash_is_rejected() { let service = Service {