From 34b1b7d80c9b3b90e54234de90b01ef295000095 Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Wed, 15 Apr 2026 17:57:08 +0800 Subject: [PATCH] feat: implement '-external-certificate' flag to edge and reencrypt route Adds support for referencing TLS certificates from a TLS Secret when creating edge and reencrypt routes. The new '--external-certificate' flag accepts a Secret name and populates 'route.Spec.TLS.ExternalCertificate' as a LocalObjectReference. This flag is mutually exclusive with '--cert' and '--key', since inline certificates and secret-backed certificates are alternative sources. The '--ca-cert' flag (and '--dest-ca-cert' for reencrypt routes) remains compatible with '--external-certificate'. --- pkg/cli/create/routeedge.go | 60 +++++--- pkg/cli/create/routeedge_test.go | 181 +++++++++++++++++++++++++ pkg/cli/create/routereenecrypt.go | 62 ++++++--- pkg/cli/create/routereenecrypt_test.go | 120 ++++++++++++++++ 4 files changed, 386 insertions(+), 37 deletions(-) create mode 100644 pkg/cli/create/routeedge_test.go create mode 100644 pkg/cli/create/routereenecrypt_test.go diff --git a/pkg/cli/create/routeedge.go b/pkg/cli/create/routeedge.go index 1a9d741e2e..4d0a9d4b9a 100644 --- a/pkg/cli/create/routeedge.go +++ b/pkg/cli/create/routeedge.go @@ -34,21 +34,25 @@ var ( # Create an edge route that exposes the frontend service and specify a path # If the route name is omitted, the service name will be used oc create route edge --service=frontend --path /assets + + # Create an edge route that uses an external certificate from a secret + oc create route edge --service=frontend --external-certificate=my-cert-secret `) ) type CreateEdgeRouteOptions struct { CreateRouteSubcommandOptions *CreateRouteSubcommandOptions - Hostname string - Port string - InsecurePolicy string - Service string - Path string - Cert string - Key string - CACert string - WildcardPolicy string + Hostname string + Port string + InsecurePolicy string + Service string + Path string + Cert string + Key string + CACert string + ExternalCertificate string + WildcardPolicy string } // NewCmdCreateEdgeRoute is a macro command to create an edge route. @@ -63,6 +67,7 @@ func NewCmdCreateEdgeRoute(f kcmdutil.Factory, streams genericiooptions.IOStream Example: edgeRouteExample, Run: func(cmd *cobra.Command, args []string) { kcmdutil.CheckErr(o.Complete(f, cmd, args)) + kcmdutil.CheckErr(o.Validate()) kcmdutil.CheckErr(o.Run()) }, } @@ -79,6 +84,7 @@ func NewCmdCreateEdgeRoute(f kcmdutil.Factory, streams genericiooptions.IOStream cmd.MarkFlagFilename("key") cmd.Flags().StringVar(&o.CACert, "ca-cert", o.CACert, "Path to a CA certificate file.") cmd.MarkFlagFilename("ca-cert") + cmd.Flags().StringVar(&o.ExternalCertificate, "external-certificate", o.ExternalCertificate, "Name of a secret that contains the TLS certificate and key. The secret must contain keys named tls.crt and tls.key. Mutually exclusive with --cert and --key.") cmd.Flags().StringVar(&o.WildcardPolicy, "wildcard-policy", o.WildcardPolicy, "Sets the WilcardPolicy for the hostname, the default is \"None\". valid values are \"None\" and \"Subdomain\"") kcmdutil.AddValidateFlags(cmd) @@ -92,6 +98,16 @@ func (o *CreateEdgeRouteOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command return o.CreateRouteSubcommandOptions.Complete(f, cmd, args) } +func (o *CreateEdgeRouteOptions) Validate() error { + if len(o.Cert) > 0 && len(o.ExternalCertificate) > 0 { + return fmt.Errorf("--cert and --external-certificate are mutually exclusive") + } + if len(o.Key) > 0 && len(o.ExternalCertificate) > 0 { + return fmt.Errorf("--key and --external-certificate are mutually exclusive") + } + return nil +} + func (o *CreateEdgeRouteOptions) Run() error { serviceName, err := resolveServiceName(o.CreateRouteSubcommandOptions.Mapper, o.Service) if err != nil { @@ -111,16 +127,24 @@ func (o *CreateEdgeRouteOptions) Run() error { route.Spec.TLS = new(routev1.TLSConfig) route.Spec.TLS.Termination = routev1.TLSTerminationEdge - cert, err := fileutil.LoadData(o.Cert) - if err != nil { - return err - } - route.Spec.TLS.Certificate = string(cert) - key, err := fileutil.LoadData(o.Key) - if err != nil { - return err + + if len(o.ExternalCertificate) > 0 { + route.Spec.TLS.ExternalCertificate = &routev1.LocalObjectReference{ + Name: o.ExternalCertificate, + } + } else { + cert, err := fileutil.LoadData(o.Cert) + if err != nil { + return err + } + route.Spec.TLS.Certificate = string(cert) + key, err := fileutil.LoadData(o.Key) + if err != nil { + return err + } + route.Spec.TLS.Key = string(key) } - route.Spec.TLS.Key = string(key) + caCert, err := fileutil.LoadData(o.CACert) if err != nil { return err diff --git a/pkg/cli/create/routeedge_test.go b/pkg/cli/create/routeedge_test.go new file mode 100644 index 0000000000..640aef5c53 --- /dev/null +++ b/pkg/cli/create/routeedge_test.go @@ -0,0 +1,181 @@ +package create + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/genericiooptions" + fakekubernetes "k8s.io/client-go/kubernetes/fake" + + routev1 "github.com/openshift/api/route/v1" + routefake "github.com/openshift/client-go/route/clientset/versioned/fake" +) + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file %s: %v", path, err) + } +} + +func newTestService() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt32(8080), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + +func newTestRouteSubcommandOptions(t *testing.T) (*CreateRouteSubcommandOptions, *routefake.Clientset, *bytes.Buffer) { + t.Helper() + service := newTestService() + streams, _, out, _ := genericiooptions.NewTestIOStreams() + fakeKubeClient := fakekubernetes.NewClientset(service) + fakeRouteClientset := routefake.NewClientset() + mapper := meta.NewDefaultRESTMapper(nil) + + printFlags := genericclioptions.NewPrintFlags("created").WithTypeSetter(createCmdScheme) + printer, err := printFlags.ToPrinter() + if err != nil { + t.Fatalf("failed to create printer: %v", err) + } + + sub := &CreateRouteSubcommandOptions{ + PrintFlags: printFlags, + Name: "my-route", + Namespace: "default", + Mapper: mapper, + Client: fakeRouteClientset.RouteV1(), + CoreClient: fakeKubeClient.CoreV1(), + Printer: printer, + IOStreams: streams, + } + + return sub, fakeRouteClientset, out +} + +func newTestEdgeRouteOptions(t *testing.T) (*CreateEdgeRouteOptions, *routefake.Clientset, *bytes.Buffer) { + t.Helper() + sub, fakeRouteClientset, out := newTestRouteSubcommandOptions(t) + o := &CreateEdgeRouteOptions{ + CreateRouteSubcommandOptions: sub, + Service: "my-service", + } + return o, fakeRouteClientset, out +} + +func TestCreateEdgeRoute_MutualExclusivity(t *testing.T) { + tests := []struct { + name string + cert string + key string + externalCertificate string + expectError string + }{ + { + name: "neither --cert nor --external-certificate set", + }, + { + name: "only --cert set", + cert: "/path/to/tls.crt", + }, + { + name: "only --external-certificate set", + externalCertificate: "my-secret", + }, + { + name: "both --cert and --external-certificate set", + cert: "/path/to/tls.crt", + externalCertificate: "my-secret", + expectError: "--cert and --external-certificate are mutually exclusive", + }, + { + name: "both --key and --external-certificate set", + key: "/path/to/tls.key", + externalCertificate: "my-secret", + expectError: "--key and --external-certificate are mutually exclusive", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o, _, _ := newTestEdgeRouteOptions(t) + o.Cert = tt.cert + o.Key = tt.key + o.ExternalCertificate = tt.externalCertificate + + err := o.Validate() + if tt.expectError != "" { + if err == nil { + t.Fatal("expected error but got nil") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Fatalf("expected error containing %q, got: %v", tt.expectError, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestCreateEdgeRoute_ExternalCertificateWiring(t *testing.T) { + o, fakeRouteClientset, _ := newTestEdgeRouteOptions(t) + o.ExternalCertificate = "my-cert-secret" + + if err := o.Run(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + routes, err := fakeRouteClientset.RouteV1().Routes("default").List(context.TODO(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("failed to list routes: %v", err) + } + if len(routes.Items) != 1 { + t.Fatalf("expected 1 route, got %d", len(routes.Items)) + } + + route := routes.Items[0] + if route.Name != "my-route" { + t.Errorf("expected route name %q, got %q", "my-route", route.Name) + } + if route.Spec.TLS == nil { + t.Fatal("expected TLS config to be set") + } + if route.Spec.TLS.ExternalCertificate == nil { + t.Fatal("expected ExternalCertificate to be set") + } + if route.Spec.TLS.ExternalCertificate.Name != "my-cert-secret" { + t.Errorf("expected ExternalCertificate.Name %q, got %q", "my-cert-secret", route.Spec.TLS.ExternalCertificate.Name) + } + if route.Spec.TLS.Certificate != "" { + t.Errorf("expected no inline certificate, got %q", route.Spec.TLS.Certificate) + } + if route.Spec.TLS.Key != "" { + t.Errorf("expected no inline key, got %q", route.Spec.TLS.Key) + } + if route.Spec.TLS.Termination != routev1.TLSTerminationEdge { + t.Errorf("expected termination %q, got %q", routev1.TLSTerminationEdge, route.Spec.TLS.Termination) + } +} + diff --git a/pkg/cli/create/routereenecrypt.go b/pkg/cli/create/routereenecrypt.go index 9425d881b2..90f4adffae 100644 --- a/pkg/cli/create/routereenecrypt.go +++ b/pkg/cli/create/routereenecrypt.go @@ -2,6 +2,7 @@ package create import ( "context" + "fmt" "github.com/spf13/cobra" @@ -34,22 +35,26 @@ var ( # route name default to the service name and the destination CA certificate # default to the service CA oc create route reencrypt --service=frontend + + # Create a reencrypt route that uses an external certificate from a secret + oc create route reencrypt --service=frontend --external-certificate=my-cert-secret --dest-ca-cert cert.cert `) ) type CreateReencryptRouteOptions struct { CreateRouteSubcommandOptions *CreateRouteSubcommandOptions - Hostname string - Port string - InsecurePolicy string - Service string - Path string - Cert string - Key string - CACert string - DestCACert string - WildcardPolicy string + Hostname string + Port string + InsecurePolicy string + Service string + Path string + Cert string + Key string + CACert string + DestCACert string + ExternalCertificate string + WildcardPolicy string } // NewCmdCreateReencryptRoute is a macro command to create a reencrypt route. @@ -64,6 +69,7 @@ func NewCmdCreateReencryptRoute(f kcmdutil.Factory, streams genericiooptions.IOS Example: reencryptRouteExample, Run: func(cmd *cobra.Command, args []string) { kcmdutil.CheckErr(o.Complete(f, cmd, args)) + kcmdutil.CheckErr(o.Validate()) kcmdutil.CheckErr(o.Run()) }, } @@ -82,6 +88,7 @@ func NewCmdCreateReencryptRoute(f kcmdutil.Factory, streams genericiooptions.IOS cmd.MarkFlagFilename("ca-cert") cmd.Flags().StringVar(&o.DestCACert, "dest-ca-cert", o.DestCACert, "Path to a CA certificate file, used for securing the connection from the router to the destination. Defaults to the Service CA.") cmd.MarkFlagFilename("dest-ca-cert") + cmd.Flags().StringVar(&o.ExternalCertificate, "external-certificate", o.ExternalCertificate, "Name of a secret that contains the TLS certificate and key. The secret must contain keys named tls.crt and tls.key. Mutually exclusive with --cert and --key.") cmd.Flags().StringVar(&o.WildcardPolicy, "wildcard-policy", o.WildcardPolicy, "Sets the WilcardPolicy for the hostname, the default is \"None\". valid values are \"None\" and \"Subdomain\"") kcmdutil.AddValidateFlags(cmd) @@ -95,6 +102,16 @@ func (o *CreateReencryptRouteOptions) Complete(f kcmdutil.Factory, cmd *cobra.Co return o.CreateRouteSubcommandOptions.Complete(f, cmd, args) } +func (o *CreateReencryptRouteOptions) Validate() error { + if len(o.Cert) > 0 && len(o.ExternalCertificate) > 0 { + return fmt.Errorf("--cert and --external-certificate are mutually exclusive") + } + if len(o.Key) > 0 && len(o.ExternalCertificate) > 0 { + return fmt.Errorf("--key and --external-certificate are mutually exclusive") + } + return nil +} + func (o *CreateReencryptRouteOptions) Run() error { serviceName, err := resolveServiceName(o.CreateRouteSubcommandOptions.Mapper, o.Service) if err != nil { @@ -121,16 +138,23 @@ func (o *CreateReencryptRouteOptions) Run() error { route.Spec.TLS = new(routev1.TLSConfig) route.Spec.TLS.Termination = routev1.TLSTerminationReencrypt - cert, err := fileutil.LoadData(o.Cert) - if err != nil { - return err - } - route.Spec.TLS.Certificate = string(cert) - key, err := fileutil.LoadData(o.Key) - if err != nil { - return err + if len(o.ExternalCertificate) > 0 { + route.Spec.TLS.ExternalCertificate = &routev1.LocalObjectReference{ + Name: o.ExternalCertificate, + } + } else { + cert, err := fileutil.LoadData(o.Cert) + if err != nil { + return err + } + route.Spec.TLS.Certificate = string(cert) + key, err := fileutil.LoadData(o.Key) + if err != nil { + return err + } + route.Spec.TLS.Key = string(key) } - route.Spec.TLS.Key = string(key) + caCert, err := fileutil.LoadData(o.CACert) if err != nil { return err diff --git a/pkg/cli/create/routereenecrypt_test.go b/pkg/cli/create/routereenecrypt_test.go new file mode 100644 index 0000000000..bb55a0bfaa --- /dev/null +++ b/pkg/cli/create/routereenecrypt_test.go @@ -0,0 +1,120 @@ +package create + +import ( + "bytes" + "context" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + routev1 "github.com/openshift/api/route/v1" + routefake "github.com/openshift/client-go/route/clientset/versioned/fake" +) + +func newTestReencryptRouteOptions(t *testing.T) (*CreateReencryptRouteOptions, *routefake.Clientset, *bytes.Buffer) { + t.Helper() + sub, fakeRouteClientset, out := newTestRouteSubcommandOptions(t) + o := &CreateReencryptRouteOptions{ + CreateRouteSubcommandOptions: sub, + Service: "my-service", + } + return o, fakeRouteClientset, out +} + +func TestCreateReencryptRoute_MutualExclusivity(t *testing.T) { + tests := []struct { + name string + cert string + key string + externalCertificate string + expectError string + }{ + { + name: "neither --cert nor --external-certificate set", + }, + { + name: "only --cert set", + cert: "/path/to/tls.crt", + }, + { + name: "only --external-certificate set", + externalCertificate: "my-secret", + }, + { + name: "both --cert and --external-certificate set", + cert: "/path/to/tls.crt", + externalCertificate: "my-secret", + expectError: "--cert and --external-certificate are mutually exclusive", + }, + { + name: "both --key and --external-certificate set", + key: "/path/to/tls.key", + externalCertificate: "my-secret", + expectError: "--key and --external-certificate are mutually exclusive", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o, _, _ := newTestReencryptRouteOptions(t) + o.Cert = tt.cert + o.Key = tt.key + o.ExternalCertificate = tt.externalCertificate + + err := o.Validate() + if tt.expectError != "" { + if err == nil { + t.Fatal("expected error but got nil") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Fatalf("expected error containing %q, got: %v", tt.expectError, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestCreateReencryptRoute_ExternalCertificateWiring(t *testing.T) { + o, fakeRouteClientset, _ := newTestReencryptRouteOptions(t) + o.ExternalCertificate = "my-cert-secret" + + if err := o.Run(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + routes, err := fakeRouteClientset.RouteV1().Routes("default").List(context.TODO(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("failed to list routes: %v", err) + } + if len(routes.Items) != 1 { + t.Fatalf("expected 1 route, got %d", len(routes.Items)) + } + + route := routes.Items[0] + if route.Name != "my-route" { + t.Errorf("expected route name %q, got %q", "my-route", route.Name) + } + if route.Spec.TLS == nil { + t.Fatal("expected TLS config to be set") + } + if route.Spec.TLS.ExternalCertificate == nil { + t.Fatal("expected ExternalCertificate to be set") + } + if route.Spec.TLS.ExternalCertificate.Name != "my-cert-secret" { + t.Errorf("expected ExternalCertificate.Name %q, got %q", "my-cert-secret", route.Spec.TLS.ExternalCertificate.Name) + } + if route.Spec.TLS.Certificate != "" { + t.Errorf("expected no inline certificate, got %q", route.Spec.TLS.Certificate) + } + if route.Spec.TLS.Key != "" { + t.Errorf("expected no inline key, got %q", route.Spec.TLS.Key) + } + if route.Spec.TLS.Termination != routev1.TLSTerminationReencrypt { + t.Errorf("expected termination %q, got %q", routev1.TLSTerminationReencrypt, route.Spec.TLS.Termination) + } +} +