From 50f7182187b13b3148d253bc5a8ccf5fcd229b6d Mon Sep 17 00:00:00 2001 From: Nathan Coleman Date: Wed, 27 Nov 2024 19:45:30 +0000 Subject: [PATCH 1/2] backport of commit ffbecb320bef3e8ad5f37089ccb4e940fc27b40c --- cli/cmd/gateway/list/command.go | 312 +++++++++++++++++++++++++++ cli/cmd/gateway/list/command_test.go | 160 ++++++++++++++ cli/commands.go | 6 + 3 files changed, 478 insertions(+) create mode 100644 cli/cmd/gateway/list/command.go create mode 100644 cli/cmd/gateway/list/command_test.go diff --git a/cli/cmd/gateway/list/command.go b/cli/cmd/gateway/list/command.go new file mode 100644 index 0000000000..ef6fc5088b --- /dev/null +++ b/cli/cmd/gateway/list/command.go @@ -0,0 +1,312 @@ +package read + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + helmcli "helm.sh/helm/v3/pkg/cli" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + "sigs.k8s.io/yaml" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/flag" + "github.com/hashicorp/consul-k8s/cli/common/terminal" +) + +type Command struct { + *common.BaseCommand + + kubernetes client.Client + restConfig *rest.Config + + set *flag.Sets + + flagAllNamespaces bool + flagGatewayKind string + flagGatewayNamespace string + flagKubeConfig string + flagKubeContext string + flagOutput string + + initOnce sync.Once + help string +} + +func (c *Command) Help() string { + c.initOnce.Do(c.init) + return fmt.Sprintf("%s\n\nUsage: consul-k8s gateway list [flags]\n\n%s", c.Synopsis(), c.help) +} + +func (c *Command) Synopsis() string { + return "Inspect the configuration for all Gateways in the Kubernetes cluster or a given Kubernetes namespace." +} + +// init establishes the flags for Command +func (c *Command) init() { + c.set = flag.NewSets() + + f := c.set.NewSet("Command Options") + f.StringVar(&flag.StringVar{ + Name: "namespace", + Target: &c.flagGatewayNamespace, + Usage: "The Kubernetes namespace to list Gateways in.", + Aliases: []string{"n"}, + }) + f.BoolVar(&flag.BoolVar{ + Name: "all-namespaces", + Target: &c.flagAllNamespaces, + Default: false, + Usage: "List Gateways in all Kubernetes namespaces.", + Aliases: []string{"A"}, + }) + f.StringVar(&flag.StringVar{ + Name: "output", + Target: &c.flagOutput, + Usage: "Output the Gateway configuration as 'json' in the terminal or 'archive' as a zip archive named 'gateways.zip' in the current directory.", + Default: "archive", + Aliases: []string{"o"}, + }) + + f = c.set.NewSet("Global Options") + f.StringVar(&flag.StringVar{ + Name: "kubeconfig", + Aliases: []string{"c"}, + Target: &c.flagKubeConfig, + Usage: "Set the path to a kubeconfig file.", + }) + f.StringVar(&flag.StringVar{ + Name: "context", + Target: &c.flagKubeContext, + Usage: "Set the Kubernetes context to use.", + }) + + c.help = c.set.Help() +} + +// Run runs the command +func (c *Command) Run(args []string) int { + c.initOnce.Do(c.init) + c.Log.ResetNamed("read") + defer common.CloseWithError(c.BaseCommand) + + if err := c.set.Parse(args); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.initKubernetes(); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.fetchCRDs(); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + return 0 +} + +type gatewayWithRoutes struct { + Gateway gwv1beta1.Gateway `json:"gateway"` + GatewayClass gwv1beta1.GatewayClass `json:"gatewayClass"` + HTTPRoutes []gwv1beta1.HTTPRoute `json:"httpRoutes"` + TCPRoutes []gwv1alpha2.TCPRoute `json:"tcpRoutes"` +} + +func (c *Command) fetchCRDs() error { + // Fetch Gateways + var gateways gwv1beta1.GatewayList + if err := c.kubernetes.List(context.Background(), &gateways, client.InNamespace(c.flagGatewayNamespace)); err != nil { + return fmt.Errorf("error fetching Gateway CRD: %w", err) + } + + // Fetch all HTTPRoutes in all namespaces + var httpRoutes gwv1beta1.HTTPRouteList + if err := c.kubernetes.List(context.Background(), &httpRoutes); err != nil { + return fmt.Errorf("error fetching HTTPRoute CRDs: %w", err) + } + + // Fetch all TCPRoutes in all namespaces + var tcpRoutes gwv1alpha2.TCPRouteList + if err := c.kubernetes.List(context.Background(), &tcpRoutes); err != nil { + return fmt.Errorf("error fetching TCPRoute CRDs: %w", err) + } + + var gws []gatewayWithRoutes + + for _, gateway := range gateways.Items { + // Fetch GatewayClass referenced by Gateway + var gatewayClass gwv1beta1.GatewayClass + if err := c.kubernetes.Get(context.Background(), client.ObjectKey{Namespace: "", Name: string(gateway.Spec.GatewayClassName)}, &gatewayClass); err != nil { + return fmt.Errorf("error fetching GatewayClass CRD: %w", err) + } + + // FUTURE Fetch GatewayClassConfig referenced by GatewayClass + // This import requires resolving hairy dependency discrepancies between modules in this repo + //var gatewayClassConfig v1alpha1.GatewayClassConfig + //if err := c.kubernetes.Get(context.Background(), client.ObjectKey{Namespace: "", Name: gatewayClass.Spec.ParametersRef.Name}, &gatewayClassConfig); err != nil { + // return fmt.Errorf("error fetching GatewayClassConfig CRD: %w", err) + //} + + // FUTURE Fetch MeshServices referenced by HTTPRoutes or TCPRoutes + // This import requires resolving hairy dependency discrepancies between modules in this repo + // var meshServices v1alpha1.MeshServiceList + // if err := c.kubernetes.List(context.Background(), &meshServices); err != nil { + // return fmt.Errorf("error fetching MeshService CRDs: %w", err) + // } + + gw := gatewayWithRoutes{ + Gateway: gateway, + GatewayClass: gatewayClass, + HTTPRoutes: make([]gwv1beta1.HTTPRoute, 0, len(httpRoutes.Items)), + TCPRoutes: make([]gwv1alpha2.TCPRoute, 0, len(tcpRoutes.Items)), + } + + for _, route := range httpRoutes.Items { + for _, ref := range route.Spec.ParentRefs { + switch { + case string(ref.Name) != gateway.Name: + // Route parent references gateway with different name + continue + case ref.Namespace != nil && string(*ref.Namespace) == gateway.Namespace: + // Route parent explicitly references gateway with same name and namespace + gw.HTTPRoutes = append(gw.HTTPRoutes, route) + case ref.Namespace == nil && route.Namespace == gateway.Namespace: + // Route parent implicitly references gateway with same name in local namespace + gw.HTTPRoutes = append(gw.HTTPRoutes, route) + } + } + } + + for _, route := range tcpRoutes.Items { + for _, ref := range route.Spec.ParentRefs { + switch { + case string(ref.Name) != gateway.Name: + // Route parent references gateway with different name + continue + case ref.Namespace != nil && string(*ref.Namespace) == gateway.Namespace: + // Route parent explicitly references gateway with same name and namespace + gw.TCPRoutes = append(gw.TCPRoutes, route) + case ref.Namespace == nil && route.Namespace == gateway.Namespace: + // Route parent implicitly references gateway with same name in local namespace + gw.TCPRoutes = append(gw.TCPRoutes, route) + } + } + } + + gws = append(gws, gw) + } + + switch strings.ToLower(c.flagOutput) { + case "json": + if err := c.writeJSONOutput(gws); err != nil { + return fmt.Errorf("error writing CRDs as JSON: %w", err) + } + default: + file, err := os.Create("./gateways.zip") + if err != nil { + return fmt.Errorf("error creating output file: %w", err) + } + + zipw := zip.NewWriter(file) + defer zipw.Close() + + if err := c.writeArchive(zipw, gws); err != nil { + return fmt.Errorf("error writing CRDs to zip archive: %w", err) + } + c.UI.Output("Wrote to zip archive " + file.Name()) + return zipw.Close() + } + + return nil +} + +func (c *Command) writeJSONOutput(obj interface{}) error { + output, err := json.MarshalIndent(obj, "", "\t") + if err != nil { + return err + } + + c.UI.Output(string(output)) + return nil +} + +// writeArchive writes one file to the zip archive for each Gateway in the list. +// The files have name `-.yaml`. +func (c *Command) writeArchive(zipw *zip.Writer, gws []gatewayWithRoutes) error { + for _, gw := range gws { + name := fmt.Sprintf("%s-%s.yaml", gw.Gateway.Namespace, gw.Gateway.Name) + + w, err := zipw.Create(name) + if err != nil { + return fmt.Errorf("error creating zip entry for %s: %w", name, err) + } + + objYaml, err := yaml.Marshal(gw) + if err != nil { + return fmt.Errorf("error marshalling %s: %w", name, err) + } + + _, err = w.Write(objYaml) + if err != nil { + return fmt.Errorf("error writing %s to zip archive: %w", name, err) + } + } + + return nil +} + +// initKubernetes initializes the REST config and uses it to initialize the k8s client. +func (c *Command) initKubernetes() (err error) { + settings := helmcli.New() + + // If a kubeconfig was specified, use it + if c.flagKubeConfig != "" { + settings.KubeConfig = c.flagKubeConfig + } + + // If a kube context was specified, use it + if c.flagKubeContext != "" { + settings.KubeContext = c.flagKubeContext + } + + // Create a REST config from the settings for our Kubernetes client + if c.restConfig == nil { + if c.restConfig, err = settings.RESTClientGetter().ToRESTConfig(); err != nil { + return fmt.Errorf("error creating Kubernetes REST config: %w", err) + } + } + + // Create a controller-runtime client from c.restConfig + if c.kubernetes == nil { + if c.kubernetes, err = client.New(c.restConfig, client.Options{}); err != nil { + return fmt.Errorf("error creating controller-runtime client: %w", err) + } + // FUTURE Fix dependency discrepancies between modules in this repo so that this scheme can be added (see above) + //_ = v1alpha1.AddToScheme(c.kubernetes.Scheme()) + _ = gwv1alpha2.AddToScheme(c.kubernetes.Scheme()) + _ = gwv1beta1.AddToScheme(c.kubernetes.Scheme()) + } + + // If all namespaces specified, use empty namespace; otherwise, if + // no namespace was specified, use the one from the kube context. + if c.flagAllNamespaces { + c.flagGatewayNamespace = "" + } else if c.flagGatewayNamespace == "" { + if c.flagOutput != "json" { + c.UI.Output("No namespace specified, using current kube context namespace: %s", settings.Namespace()) + } + c.flagGatewayNamespace = settings.Namespace() + } + + return nil +} diff --git a/cli/cmd/gateway/list/command_test.go b/cli/cmd/gateway/list/command_test.go new file mode 100644 index 0000000000..e61d28a3ab --- /dev/null +++ b/cli/cmd/gateway/list/command_test.go @@ -0,0 +1,160 @@ +package read + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/terminal" +) + +func TestFlagParsing(t *testing.T) { + cases := map[string]struct { + args []string + out int + }{ + "No args": { + args: []string{}, + out: 1, + }, + "Multiple gateway names passed": { + args: []string{"gateway-1", "gateway-2"}, + out: 1, + }, + "Nonexistent flag passed, -foo bar": { + args: []string{"gateway-1", "-foo", "bar"}, + out: 1, + }, + "Invalid argument passed, -namespace YOLO": { + args: []string{"gateway-1", "-namespace", "YOLO"}, + out: 1, + }, + "User passed incorrect output": { + args: []string{"gateway-1", "-output", "image"}, + out: 1, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := setupCommand(new(bytes.Buffer)) + c.kubernetes = fake.NewClientBuilder().WithObjectTracker(nil).Build() + + out := c.Run(tc.args) + require.Equal(t, tc.out, out) + }) + } +} + +func TestReadCommandOutput(t *testing.T) { + gatewayClassName := "gateway-class-1" + gatewayName := "gateway-1" + routeName := "route-1" + + fakeGatewayClass := &gwv1beta1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: gatewayClassName, + }, + } + + fakeGateway := &gwv1beta1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: gatewayName, + }, + Spec: gwv1beta1.GatewaySpec{ + GatewayClassName: gwv1beta1.ObjectName(gatewayClassName), + }, + } + + fakeHTTPRoute := &gwv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: routeName, + }, + Spec: gwv1beta1.HTTPRouteSpec{ + CommonRouteSpec: gwv1beta1.CommonRouteSpec{ + ParentRefs: []gwv1beta1.ParentReference{ + { + Name: gwv1beta1.ObjectName(fakeGateway.Name), + }, + }, + }, + }, + } + + fakeUnattachedHTTPRoute := &gwv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "route-2", + }, + } + + buf := new(bytes.Buffer) + c := setupCommand(buf) + + scheme := scheme.Scheme + gwv1beta1.AddToScheme(scheme) + gwv1alpha2.AddToScheme(scheme) + + c.kubernetes = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(fakeGatewayClass, fakeGateway, fakeHTTPRoute, fakeUnattachedHTTPRoute). + Build() + + out := c.Run([]string{"-output", "json"}) + require.Equal(t, 0, out) + + gatewaysWithRoutes := []struct { + Gateway gwv1beta1.Gateway `json:"Gateway"` + GatewayClass gwv1beta1.GatewayClass `json:"GatewayClass"` + HTTPRoutes []gwv1beta1.HTTPRoute `json:"HTTPRoutes"` + }{} + require.NoErrorf(t, json.Unmarshal(buf.Bytes(), &gatewaysWithRoutes), "failed to parse JSON output %s", buf.String()) + require.Len(t, gatewaysWithRoutes, 1) + + gatewayWithRoutes := gatewaysWithRoutes[0] + + // Make gateway assertions + assert.Equal(t, gatewayName, gatewayWithRoutes.Gateway.Name) + + // Make gateway class assertions + assert.Equal(t, gatewayClassName, gatewayWithRoutes.GatewayClass.Name) + + // Make http route assertions + require.Len(t, gatewayWithRoutes.HTTPRoutes, 1) + assert.Equal(t, routeName, gatewayWithRoutes.HTTPRoutes[0].Name) +} + +func setupCommand(buf io.Writer) *Command { + // Log at a test level to standard out. + log := hclog.New(&hclog.LoggerOptions{ + Name: "test", + Level: hclog.Debug, + Output: os.Stdout, + }) + + // Setup and initialize the command struct + command := &Command{ + BaseCommand: &common.BaseCommand{ + Log: log, + UI: terminal.NewUI(context.Background(), buf), + }, + } + command.init() + + return command +} diff --git a/cli/commands.go b/cli/commands.go index 4de8530116..ca06d5d679 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/consul-k8s/cli/cmd/config" config_read "github.com/hashicorp/consul-k8s/cli/cmd/config/read" + gwlist "github.com/hashicorp/consul-k8s/cli/cmd/gateway/list" gwread "github.com/hashicorp/consul-k8s/cli/cmd/gateway/read" "github.com/hashicorp/consul-k8s/cli/cmd/install" "github.com/hashicorp/consul-k8s/cli/cmd/proxy" @@ -64,6 +65,11 @@ func initializeCommands(ctx context.Context, log hclog.Logger) (*common.BaseComm Version: version.GetHumanVersion(), }, nil }, + "gateway list": func() (cli.Command, error) { + return &gwlist.Command{ + BaseCommand: baseCommand, + }, nil + }, "gateway read": func() (cli.Command, error) { return &gwread.Command{ BaseCommand: baseCommand, From fb620615d0514498d46fa7d8ffd2ec2a2a900b2f Mon Sep 17 00:00:00 2001 From: Nathan Coleman Date: Sat, 30 Nov 2024 14:55:32 +0000 Subject: [PATCH 2/2] backport of commit d5a0b69e5d7c044e3e70155f76261adf9b8f4411 --- .changelog/4433.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/4433.txt diff --git a/.changelog/4433.txt b/.changelog/4433.txt new file mode 100644 index 0000000000..d9932af4a2 --- /dev/null +++ b/.changelog/4433.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cli: Introduce `gateway list` for collecting multiple components of all gateways' configuration by running a single command. +```