From 317a2f843d7d4733dcf7a5903102ebd84c56a0be Mon Sep 17 00:00:00 2001 From: Lin Yang Date: Wed, 30 Oct 2024 00:04:47 +0800 Subject: [PATCH] feat: integrate gwctl code and bump deps (#404) * feat: integrate gwctl code and bump deps Signed-off-by: Lin Yang * fix: golang lint Signed-off-by: Lin Yang * build(deps): bump helm from 3.14.3 to 3.16.2 (#403) Signed-off-by: Lin Yang * [security]fix: Vault Community Edition privilege escalation vulnerability (#405) * feat: integrate gwctl code and bump deps Signed-off-by: Lin Yang * fix: golang lint Signed-off-by: Lin Yang * fix: go mod tidy Signed-off-by: Lin Yang --------- Signed-off-by: Lin Yang --- Makefile | 5 +- cmd/cli/analyze/analyze.go | 345 +++++++++++++ cmd/cli/apply/apply.go | 139 ++++++ cmd/cli/delete/delete.go | 118 +++++ cmd/cli/fsm.go | 8 +- cmd/cli/get/get.go | 308 ++++++++++++ cmd/cli/proxy.go | 4 +- cmd/cli/proxy_get.go | 3 +- go.mod | 12 +- go.sum | 29 +- pkg/cli/common/clients.go | 88 ++++ pkg/cli/common/errors.go | 82 +++ pkg/cli/common/factory.go | 43 ++ pkg/cli/common/testhelpers.go | 86 ++++ pkg/cli/common/types.go | 90 ++++ pkg/cli/environment.go | 4 +- .../directlyattachedpolicy.go | 80 +++ pkg/cli/extension/extensions.go | 44 ++ .../gatewayeffectivepolicy.go | 472 ++++++++++++++++++ .../notfoundrefvalidator.go | 107 ++++ .../refgrantvalidator/refgrantvalidator.go | 303 +++++++++++ pkg/cli/extension/utils/utils.go | 42 ++ pkg/cli/flags/flags.go | 77 +++ pkg/cli/policymanager/helpers.go | 30 ++ pkg/cli/policymanager/manager.go | 324 ++++++++++++ pkg/cli/policymanager/merger.go | 204 ++++++++ pkg/cli/policymanager/merger_test.go | 447 +++++++++++++++++ pkg/cli/policymanager/types.go | 5 + pkg/cli/printer/backends.go | 193 +++++++ pkg/cli/printer/backends_test.go | 49 ++ pkg/cli/printer/gatewayclasses.go | 149 ++++++ pkg/cli/printer/gatewayclasses_test.go | 49 ++ pkg/cli/printer/gateways.go | 223 +++++++++ pkg/cli/printer/gateways_test.go | 49 ++ pkg/cli/printer/httproutes.go | 165 ++++++ pkg/cli/printer/httproutes_test.go | 49 ++ pkg/cli/printer/main_test.go | 238 +++++++++ pkg/cli/printer/namespace.go | 113 +++++ pkg/cli/printer/namespace_test.go | 49 ++ pkg/cli/printer/policies.go | 186 +++++++ pkg/cli/printer/policies_test.go | 49 ++ pkg/cli/printer/printer.go | 206 ++++++++ pkg/cli/printer/types.go | 5 + pkg/cli/printer/utils.go | 258 ++++++++++ pkg/cli/printer/utils_test.go | 158 ++++++ pkg/cli/topology/gateway/gateway.go | 299 +++++++++++ pkg/cli/topology/gateway/graphviz.go | 136 +++++ pkg/cli/topology/graph.go | 377 ++++++++++++++ pkg/cli/topology/graph_test.go | 234 +++++++++ pkg/cli/topology/utils.go | 30 ++ 50 files changed, 6733 insertions(+), 30 deletions(-) create mode 100644 cmd/cli/analyze/analyze.go create mode 100644 cmd/cli/apply/apply.go create mode 100644 cmd/cli/delete/delete.go create mode 100644 cmd/cli/get/get.go create mode 100644 pkg/cli/common/clients.go create mode 100644 pkg/cli/common/errors.go create mode 100644 pkg/cli/common/factory.go create mode 100644 pkg/cli/common/testhelpers.go create mode 100644 pkg/cli/common/types.go create mode 100644 pkg/cli/extension/directlyattachedpolicy/directlyattachedpolicy.go create mode 100644 pkg/cli/extension/extensions.go create mode 100644 pkg/cli/extension/gatewayeffectivepolicy/gatewayeffectivepolicy.go create mode 100644 pkg/cli/extension/notfoundrefvalidator/notfoundrefvalidator.go create mode 100644 pkg/cli/extension/refgrantvalidator/refgrantvalidator.go create mode 100644 pkg/cli/extension/utils/utils.go create mode 100644 pkg/cli/flags/flags.go create mode 100644 pkg/cli/policymanager/helpers.go create mode 100644 pkg/cli/policymanager/manager.go create mode 100644 pkg/cli/policymanager/merger.go create mode 100644 pkg/cli/policymanager/merger_test.go create mode 100644 pkg/cli/policymanager/types.go create mode 100644 pkg/cli/printer/backends.go create mode 100644 pkg/cli/printer/backends_test.go create mode 100644 pkg/cli/printer/gatewayclasses.go create mode 100644 pkg/cli/printer/gatewayclasses_test.go create mode 100644 pkg/cli/printer/gateways.go create mode 100644 pkg/cli/printer/gateways_test.go create mode 100644 pkg/cli/printer/httproutes.go create mode 100644 pkg/cli/printer/httproutes_test.go create mode 100644 pkg/cli/printer/main_test.go create mode 100644 pkg/cli/printer/namespace.go create mode 100644 pkg/cli/printer/namespace_test.go create mode 100644 pkg/cli/printer/policies.go create mode 100644 pkg/cli/printer/policies_test.go create mode 100644 pkg/cli/printer/printer.go create mode 100644 pkg/cli/printer/types.go create mode 100644 pkg/cli/printer/utils.go create mode 100644 pkg/cli/printer/utils_test.go create mode 100644 pkg/cli/topology/gateway/gateway.go create mode 100644 pkg/cli/topology/gateway/graphviz.go create mode 100644 pkg/cli/topology/graph.go create mode 100644 pkg/cli/topology/graph_test.go create mode 100644 pkg/cli/topology/utils.go diff --git a/Makefile b/Makefile index ede2502c..48e63637 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,6 @@ DOCKER_BUILDX_PLATFORM ?= linux/amd64 DOCKER_BUILDX_OUTPUT ?= type=registry LDFLAGS ?= "-X $(BUILD_DATE_VAR)=$(BUILD_DATE) -X $(BUILD_VERSION_VAR)=$(VERSION) -X $(BUILD_GITCOMMIT_VAR)=$(GIT_SHA) -s -w" -LDFLAGS_BUILD_CROSS ?= "-X $(BUILD_DATE_VAR)=$(BUILD_DATE) -X $(BUILD_VERSION_VAR)=$(VERSION) -X $(BUILD_GITCOMMIT_VAR)=$(GIT_SHA) -s -w -extldflags '-static'" # These two values are combined and passed to go test E2E_FLAGS ?= -installType=KindCluster @@ -75,7 +74,7 @@ build: charts-tgz manifests go-fmt go-vet ## Build commands with release args, t .PHONY: build-fsm build-fsm: helm-update-dep cmd/cli/chart.tgz - CGO_ENABLED=1 go build -v -o ./bin/fsm -ldflags ${LDFLAGS} ./cmd/cli + CGO_ENABLED=0 go build -v -o ./bin/fsm -ldflags ${LDFLAGS} ./cmd/cli cmd/cli/chart.tgz: scripts/generate_chart/generate_chart.go $(shell find charts/fsm) go run $< --chart-name=fsm > $@ @@ -362,7 +361,7 @@ install-git-pre-push-hook: .PHONY: build-cross build-cross: helm-update-dep cmd/cli/chart.tgz - GO111MODULE=on CGO_ENABLED=1 $(GOX) -cgo -ldflags $(LDFLAGS_BUILD_CROSS) -parallel=5 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)" -osarch='$(TARGETS)' ./cmd/cli + GO111MODULE=on CGO_ENABLED=0 $(GOX) -ldflags $(LDFLAGS) -parallel=5 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)" -osarch='$(TARGETS)' ./cmd/cli .PHONY: dist dist: diff --git a/cmd/cli/analyze/analyze.go b/cmd/cli/analyze/analyze.go new file mode 100644 index 00000000..38c0c5ce --- /dev/null +++ b/cmd/cli/analyze/analyze.go @@ -0,0 +1,345 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package analyze + +import ( + "fmt" + "os" + "slices" + "sort" + "strings" + + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/cli-runtime/pkg/resource" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/extension" + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/gatewayeffectivepolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/notfoundrefvalidator" + "github.com/flomesh-io/fsm/pkg/cli/extension/refgrantvalidator" + extensionutils "github.com/flomesh-io/fsm/pkg/cli/extension/utils" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +func NewCmd(factory common.Factory, iostreams genericiooptions.IOStreams) *cobra.Command { + flags := &analyzeFlags{ + fileNameFlags: genericclioptions.NewResourceBuilderFlags().FileNameFlags, + } + + cmd := &cobra.Command{ + Use: "analyze -f FILENAME|DIRECTORY", + Short: "Analyze resources by file names or stdin", + Run: func(_ *cobra.Command, args []string) { + o, err := flags.ToOptions(args, factory, iostreams) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + + err = o.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + }, + } + + flags.fileNameFlags.AddFlags(cmd.Flags()) + return cmd +} + +// analyzeFlags contains the flags used with analyze command. +type analyzeFlags struct { + fileNameFlags *genericclioptions.FileNameFlags +} + +func (f *analyzeFlags) ToOptions(_ []string, factory common.Factory, iostreams genericiooptions.IOStreams) (*analyzeOptions, error) { + namespace, _, _ := factory.KubeConfigNamespace() + + return &analyzeOptions{ + fileNameOptions: f.fileNameFlags.ToOptions(), + factory: factory, + namespace: namespace, + IOStreams: iostreams, + }, nil +} + +type analyzeOptions struct { + fileNameOptions resource.FilenameOptions + factory common.Factory + namespace string + + genericclioptions.IOStreams +} + +func (o *analyzeOptions) Run() error { + fmt.Fprintf(o.IOStreams.Out, "\n") + fmt.Fprintf(o.IOStreams.Out, "Analyzing %v...\n", strings.Join(o.fileNameOptions.Filenames, ",")) + fmt.Fprintf(o.IOStreams.Out, "\n") + + // Step 1: Parse the files and extract the objects from the files. + infos, err := o.factory.NewBuilder(). + Unstructured(). + FilenameParam(false, &o.fileNameOptions). + Flatten(). + NamespaceParam(o.namespace).DefaultNamespace(). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return err + } + + // Step 2: Classify whether the object already exists, or not. If it already + // exists, cache the version which already exists. + existingObjects := map[*resource.Info]*unstructured.Unstructured{} + for _, info := range infos { + helper := resource.NewHelper(info.Client, info.Mapping) + obj, err := helper.Get(info.Namespace, info.Name) //nolint:govet + if err != nil { + if !apierrors.IsNotFound(err) { + return err + } + existingObjects[info] = nil // Object does not exist. + continue + } + // Object does exist, cache it. + o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: o} + existingObjects[info] = u + } + + // Step 3: Build graph using the provided objects in the files as the + // source. + sources := []*unstructured.Unstructured{} + for _, info := range infos { + o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) //nolint:govet + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: o} + sources = append(sources, u) + } + graph, err := topology.NewBuilder(common.NewDefaultGroupKindFetcher(o.factory, common.WithAdditionalResources(sources))). + StartFrom(sources). + UseRelationships(topologygw.AllRelations). + WithMaxDepth(4). + Build() + if err != nil { + return err + } + + policyManager := policymanager.New(common.NewDefaultGroupKindFetcher(o.factory, common.WithAdditionalResources(sources))) + if err := policyManager.Init(); err != nil { //nolint:govet + return err + } + // Execute extensions. + err = extension.ExecuteAll(graph, + directlyattachedpolicy.NewExtension(policyManager), + gatewayeffectivepolicy.NewExtension(), + refgrantvalidator.NewExtension( + refgrantvalidator.NewDefaultReferenceGrantFetcher(o.factory, refgrantvalidator.WithAdditionalResources(sources)), + ), + notfoundrefvalidator.NewExtension(), + ) + if err != nil { + return err + } + + // Step 4: Collect errors from the graph. These are the collective set of + // errors which will be observed after the new changes are applied. + errorsAfterChanges, err := collectErrors(graph) + if err != nil { + return err + } + + // Step 5: Remove nodes from the graph which are going to be newly created, + // or revert them to their state before creation. The resulting graph should + // represent a state which currently exists in the server (before applying + // the newer changes.) + for info, existingObject := range existingObjects { + gknn := common.GKNN{ + Group: info.Mapping.GroupVersionKind.Group, + Kind: info.Mapping.GroupVersionKind.Kind, + Namespace: info.Namespace, + Name: info.Name, + } + if existingObject == nil { + // This means the object would have been newly created, and thus we + // need to delete it to revert the graph back to it's original + // state. + graph.DeleteNodeUsingGKNN(gknn) + } else if !graph.HasNode(gknn) { + node := graph.Nodes[gknn.GroupKind()][gknn.NamespacedName()] + node.Object = existingObject // Revert object back to it's original state which exists in the server. + } + } + + // Step 6: Build new graph by running extensions + policyManager = policymanager.New(common.NewDefaultGroupKindFetcher(o.factory)) + if err := policyManager.Init(); err != nil { //nolint:govet + return err + } + // Execute extensions. + err = extension.ExecuteAll(graph, + directlyattachedpolicy.NewExtension(policyManager), + gatewayeffectivepolicy.NewExtension(), + refgrantvalidator.NewExtension( + refgrantvalidator.NewDefaultReferenceGrantFetcher(o.factory), + ), + notfoundrefvalidator.NewExtension(), + ) + if err != nil { + return err + } + + // Step 6: Collect errors from the graph. These are the collective set of + // errors which will be observed in the server before the new changes are + // applied. + errorsBeforeChanges, err := collectErrors(graph) + if err != nil { + return err + } + + // Step 7: Report analysis + + fmt.Fprintf(o.IOStreams.Out, "Summary:\n") + fmt.Fprintf(o.IOStreams.Out, "\n") + created, updated := generateSummary(existingObjects) + for _, info := range created { + fmt.Fprintf(o.IOStreams.Out, "\t- Created %v", info.ObjectName()) + if info.Namespaced() { + fmt.Fprintf(o.IOStreams.Out, " in namespace %v", info.Namespace) + } + fmt.Fprintf(o.IOStreams.Out, "\n") + } + for _, info := range updated { + fmt.Fprintf(o.IOStreams.Out, "\t- Updated %v", info.ObjectName()) + if info.Namespaced() { + fmt.Fprintf(o.IOStreams.Out, " in namespace %v", info.Namespace) + } + fmt.Fprintf(o.IOStreams.Out, "\n") + } + fmt.Fprintf(o.IOStreams.Out, "\n") + + newIssues, fixedIssues, unchangedIssues := classifyErrors(errorsBeforeChanges, errorsAfterChanges) + + fmt.Fprintf(o.IOStreams.Out, "Potential Issues Introduced\n") + fmt.Fprintf(o.IOStreams.Out, "(These issues will arise after applying the changes in the analyzed file.):\n") + fmt.Fprintf(o.IOStreams.Out, "\n") + for _, s := range newIssues { + fmt.Fprintf(o.IOStreams.Out, "\t- %v:\n", s) + } + if len(newIssues) == 0 { + fmt.Fprintf(o.IOStreams.Out, "\tNone.\n") + } + fmt.Fprintf(o.IOStreams.Out, "\n") + + fmt.Fprintf(o.IOStreams.Out, "Existing Issues Fixed\n") + fmt.Fprintf(o.IOStreams.Out, "(These issues were present before the changes but will be resolved after applying them.):\n") + fmt.Fprintf(o.IOStreams.Out, "\n") + for _, s := range fixedIssues { + fmt.Fprintf(o.IOStreams.Out, "\t- %v:\n", s) + } + if len(fixedIssues) == 0 { + fmt.Fprintf(o.IOStreams.Out, "\tNone\n") + } + fmt.Fprintf(o.IOStreams.Out, "\n") + + fmt.Fprintf(o.IOStreams.Out, "Existing Issues Unchanged\n") + fmt.Fprintf(o.IOStreams.Out, "(These issues were present before the changes and will remain even after applying them.):\n") + fmt.Fprintf(o.IOStreams.Out, "\n") + for _, s := range unchangedIssues { + fmt.Fprintf(o.IOStreams.Out, "\t- %v:\n", s) + } + if len(unchangedIssues) == 0 { + fmt.Fprintf(o.IOStreams.Out, "\tNone\n") + } + fmt.Fprintf(o.IOStreams.Out, "\n") + + return nil +} + +func collectErrors(graph *topology.Graph) (map[string]bool, error) { + errors := map[string]bool{} + for i := range graph.Nodes { + for j := range graph.Nodes[i] { + node := graph.Nodes[i][j] + aggregateAnalysisErrors, err := extensionutils.AggregateAnalysisErrors(node) + if err != nil { + return nil, err + } + for _, err := range aggregateAnalysisErrors { + s := fmt.Sprintf("%v: %v", node.GKNN(), err) + errors[s] = true + } + } + } + return errors, nil +} + +func generateSummary(objects map[*resource.Info]*unstructured.Unstructured) (created, updated []*resource.Info) { + for info, existingObject := range objects { + if existingObject == nil { + created = append(created, info) + } else { + updated = append(updated, info) + } + } + infoComparer := func(a, b *resource.Info) bool { + p := fmt.Sprintf("%v/%v/%v", a.Object.GetObjectKind().GroupVersionKind().GroupKind(), a.Namespace, a.Name) + q := fmt.Sprintf("%v/%v/%v", b.Object.GetObjectKind().GroupVersionKind().GroupKind(), b.Namespace, b.Name) + return p < q + } + sort.Slice(created, func(i, j int) bool { return infoComparer(created[i], created[j]) }) + sort.Slice(updated, func(i, j int) bool { return infoComparer(updated[i], updated[j]) }) + return created, updated +} + +func classifyErrors(errorsBeforeChanges, errorsAfterChanges map[string]bool) (newIssues, fixedIssues, unchangedIssues []string) { + for s := range errorsAfterChanges { + existsBefore := errorsBeforeChanges[s] + if !existsBefore { + newIssues = append(newIssues, s) + } else { + unchangedIssues = append(unchangedIssues, s) + } + } + for s := range errorsBeforeChanges { + existsAfter := errorsAfterChanges[s] + if !existsAfter { + fixedIssues = append(fixedIssues, s) + } else { + unchangedIssues = append(unchangedIssues, s) + } + } + slices.Sort(newIssues) + slices.Sort(fixedIssues) + slices.Sort(unchangedIssues) + return newIssues, fixedIssues, unchangedIssues +} diff --git a/cmd/cli/apply/apply.go b/cmd/cli/apply/apply.go new file mode 100644 index 00000000..54ce2045 --- /dev/null +++ b/cmd/cli/apply/apply.go @@ -0,0 +1,139 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apply + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/utils/ptr" + + "github.com/flomesh-io/fsm/pkg/cli/common" +) + +const ( + fieldManager = "gwctl-server-side-apply" +) + +func NewCmd(factory common.Factory, iostreams genericiooptions.IOStreams) *cobra.Command { + fileNameFlags := genericclioptions.NewResourceBuilderFlags().FileNameFlags + fileNameFlags.Usage = "The files that contain the configurations to apply." + fileNameFlags.Recursive = ptr.To(false) + fileNameFlags.Kustomize = ptr.To("") + + flags := &applyFlags{ + fileNameFlags: fileNameFlags, + } + + cmd := &cobra.Command{ + Use: "apply -f FILENAME|DIRECTORY", + Short: "Apply the provided resources from file or stdin to the cluster.", + Args: cobra.ExactArgs(0), + Run: func(_ *cobra.Command, args []string) { + o, err := flags.ToOptions(args, factory, iostreams) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + + err = o.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + }, + } + + flags.fileNameFlags.AddFlags(cmd.Flags()) + return cmd +} + +// applyFlags contains the flags used with apply command. +type applyFlags struct { + fileNameFlags *genericclioptions.FileNameFlags +} + +func (f *applyFlags) ToOptions(_ []string, factory common.Factory, iostreams genericiooptions.IOStreams) (*applyOptions, error) { + namespace, _, _ := factory.KubeConfigNamespace() + + return &applyOptions{ + fileNameOptions: f.fileNameFlags.ToOptions(), + factory: factory, + namespace: namespace, + IOStreams: iostreams, + }, nil +} + +type applyOptions struct { + fileNameOptions resource.FilenameOptions + factory common.Factory + namespace string + + genericclioptions.IOStreams +} + +func (o *applyOptions) Run() error { + infos, err := o.factory.NewBuilder(). + Unstructured(). + FilenameParam(false, &o.fileNameOptions). + Flatten(). + NamespaceParam(o.namespace).DefaultNamespace(). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return err + } + + printer := printers.NamePrinter{Operation: "configured"} + + // Loop over all objects from the file(s) or stdin. + for _, info := range infos { + helper := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(fieldManager) + + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, info.Object) + if err != nil { + return fmt.Errorf("%v: %v", info.Source, err) + } + + obj, err := helper.Patch( + info.Namespace, + info.Name, + types.ApplyPatchType, + data, + nil, + ) + if err != nil { + return err + } + + err = printer.PrintObj(obj, o.Out) + if err != nil { + return err + } + } + + return nil +} diff --git a/cmd/cli/delete/delete.go b/cmd/cli/delete/delete.go new file mode 100644 index 00000000..7677f8ed --- /dev/null +++ b/cmd/cli/delete/delete.go @@ -0,0 +1,118 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package delete + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/utils/ptr" + + "github.com/flomesh-io/fsm/pkg/cli/common" +) + +func NewCmd(factory common.Factory, iostreams genericiooptions.IOStreams) *cobra.Command { + fileNameFlags := genericclioptions.NewResourceBuilderFlags().FileNameFlags + fileNameFlags.Usage = "The files that contain the configurations to apply." + fileNameFlags.Recursive = ptr.To(false) + fileNameFlags.Kustomize = ptr.To("") + + flags := &deleteFlags{ + fileNameFlags: fileNameFlags, + } + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete resources by file names, stdin, resources and names, or by resources and label selector.", + Run: func(_ *cobra.Command, args []string) { + o, err := flags.ToOptions(args, factory, iostreams) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + + err = o.Run(args) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + }, + } + + flags.fileNameFlags.AddFlags(cmd.Flags()) + return cmd +} + +// deleteFlags contains the flags used with delete command. +type deleteFlags struct { + fileNameFlags *genericclioptions.FileNameFlags +} + +func (f *deleteFlags) ToOptions(_ []string, factory common.Factory, iostreams genericiooptions.IOStreams) (*deleteOptions, error) { + namespace, _, _ := factory.KubeConfigNamespace() + + return &deleteOptions{ + fileNameOptions: f.fileNameFlags.ToOptions(), + factory: factory, + namespace: namespace, + IOStreams: iostreams, + }, nil +} + +type deleteOptions struct { + fileNameOptions resource.FilenameOptions + factory common.Factory + namespace string + + genericclioptions.IOStreams +} + +func (o *deleteOptions) Run(args []string) error { + infos, err := o.factory.NewBuilder(). + Unstructured(). + FilenameParam(false, &o.fileNameOptions). + ResourceTypeOrNameArgs(false, args...).RequireObject(false). + Flatten(). + NamespaceParam(o.namespace).DefaultNamespace(). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return err + } + + // Loop over all objects from the file(s)/stdin/args. + for _, info := range infos { + helper := resource.NewHelper(info.Client, info.Mapping) + _, err := helper.Delete(info.Namespace, info.Name) + if err != nil { + if !apierrors.IsNotFound(err) { + return err + } + fmt.Fprintf(o.IOStreams.Out, "Error when deleting %v: %v\n", info.ObjectName(), err) + } else { + fmt.Fprintf(o.IOStreams.Out, "%v deleted\n", info.ObjectName()) + } + } + + return nil +} diff --git a/cmd/cli/fsm.go b/cmd/cli/fsm.go index 087a23c0..2b30a1ce 100644 --- a/cmd/cli/fsm.go +++ b/cmd/cli/fsm.go @@ -5,10 +5,10 @@ import ( "fmt" "os" - cmdanalyze "sigs.k8s.io/gwctl/cmd/analyze" - cmdapply "sigs.k8s.io/gwctl/cmd/apply" - cmddelete "sigs.k8s.io/gwctl/cmd/delete" - cmdget "sigs.k8s.io/gwctl/cmd/get" + cmdanalyze "github.com/flomesh-io/fsm/cmd/cli/analyze" + cmdapply "github.com/flomesh-io/fsm/cmd/cli/apply" + cmddelete "github.com/flomesh-io/fsm/cmd/cli/delete" + cmdget "github.com/flomesh-io/fsm/cmd/cli/get" "github.com/spf13/pflag" diff --git a/cmd/cli/get/get.go b/cmd/cli/get/get.go new file mode 100644 index 00000000..69a74965 --- /dev/null +++ b/cmd/cli/get/get.go @@ -0,0 +1,308 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package get + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/utils/clock" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/extension" + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/gatewayeffectivepolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/notfoundrefvalidator" + "github.com/flomesh-io/fsm/pkg/cli/extension/refgrantvalidator" + gwctlflags "github.com/flomesh-io/fsm/pkg/cli/flags" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/printer" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +func NewCmd(factory common.Factory, iostreams genericiooptions.IOStreams, isDescribe bool) *cobra.Command { + flags := newGetFlags() + + cmdName := "get" + if isDescribe { + cmdName = "describe" + } + + cmd := &cobra.Command{ + Use: fmt.Sprintf("%v TYPE [RESOURCE_NAME]", cmdName), + Short: "Display one or many resources", + Args: cobra.RangeArgs(1, 2), + Run: func(_ *cobra.Command, args []string) { + o, err := flags.ToOptions(args, factory, iostreams, isDescribe) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + + err = o.Run(args) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + }, + } + + flags.resourceBuilderFlags.AddFlags(cmd.Flags()) + + if !isDescribe { + printableAllowedFormats := strings.Join(printer.AllowedOutputFormatsForHelp(), ",") + cmd.Flags().StringVarP(&flags.outputFormat, "output", "o", "", fmt.Sprintf("Output format. Must be one of: %v", printableAllowedFormats)) + + flags.forFlag.AddFlag(cmd.Flags()) + } + + return cmd +} + +// getFlags contains the flags used with get command. +type getFlags struct { + resourceBuilderFlags *genericclioptions.ResourceBuilderFlags + outputFormat string + forFlag gwctlflags.ForFlag +} + +func newGetFlags() *getFlags { + resourceBuilderFlags := genericclioptions.NewResourceBuilderFlags(). + WithAllNamespaces(false). + WithLabelSelector("") + resourceBuilderFlags.FileNameFlags = nil + + return &getFlags{ + resourceBuilderFlags: resourceBuilderFlags, + } +} + +func (f *getFlags) ToOptions(args []string, factory common.Factory, iostreams genericiooptions.IOStreams, isDescribe bool) (*getOptions, error) { + o := &getOptions{ + isDescribe: isDescribe, + factory: factory, + IOStreams: iostreams, + allNamespaces: *f.resourceBuilderFlags.AllNamespaces, + labelSelector: *f.resourceBuilderFlags.LabelSelector, + } + + var err error + o.isPolicy, o.isPolicyCRD, err = parseResourceTypeOrNameArgs(args) + if err != nil { + return nil, err + } + + o.namespace, _, err = factory.KubeConfigNamespace() + if err != nil { + return nil, err + } + + // Parse outputFormat + o.output, err = printer.ValidateAndReturnOutputFormat(f.outputFormat) + if err != nil { + return nil, err + } + + return o, nil +} + +type getOptions struct { + isDescribe bool + + factory common.Factory + + allNamespaces bool + namespace string + labelSelector string + output printer.OutputFormat + + isPolicy bool + isPolicyCRD bool + + genericclioptions.IOStreams +} + +func (o *getOptions) Run(args []string) error { + if o.isPolicy || o.isPolicyCRD { + return o.handlePolicy(args) + } + infos, err := o.factory.NewBuilder(). + Unstructured(). + Flatten(). + NamespaceParam(o.namespace).DefaultNamespace().AllNamespaces(o.allNamespaces). + ResourceTypeOrNameArgs(true, args...). + LabelSelectorParam(o.labelSelector). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return err + } + + sources := []*unstructured.Unstructured{} + for _, info := range infos { + o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) //nolint:govet + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: o} + sources = append(sources, u) + } + + var graph *topology.Graph + if o.isDescribe || o.output == printer.OutputFormatWide || o.output == printer.OutputFormatGraph { + graph, err = topology.NewBuilder(common.NewDefaultGroupKindFetcher(o.factory)). + StartFrom(sources). + UseRelationships(topologygw.AllRelations). + Build() + if err != nil { + return err + } + + policyManager := policymanager.New(common.NewDefaultGroupKindFetcher(o.factory)) + if err := policyManager.Init(); err != nil { //nolint:govet + return err + } + + err := extension.ExecuteAll(graph, //nolint:govet + directlyattachedpolicy.NewExtension(policyManager), + gatewayeffectivepolicy.NewExtension(), + refgrantvalidator.NewExtension(refgrantvalidator.NewDefaultReferenceGrantFetcher(o.factory)), + notfoundrefvalidator.NewExtension(), + ) + if err != nil { + return err + } + } else { + graph, err = topology.NewBuilder(common.NewDefaultGroupKindFetcher(o.factory)). + StartFrom(sources). + Build() + if err != nil { + return err + } + } + + if o.output == printer.OutputFormatGraph { + toDotGraph, err := topologygw.ToDot(graph) + if err != nil { + return err + } + fmt.Fprintf(o.IOStreams.Out, "%v\n", string(toDotGraph)) + + return nil + } + + return o.printNodes(graph.Sources) +} + +func (o *getOptions) handlePolicy(args []string) error { + policyManager := policymanager.New(common.NewDefaultGroupKindFetcher(o.factory)) + if err := policyManager.Init(); err != nil { + return err + } + + nodes := []*topology.Node{} + if o.isPolicy { + for _, policy := range policyManager.GetPolicies() { + shouldSkip := (!o.allNamespaces && o.namespace != policy.GKNN().Namespace) || + (len(args) == 2 && args[1] != policy.GKNN().Name) + if shouldSkip { + continue + } + nodes = append(nodes, encodePolicyAsNode(policy)) + } + } else { + for _, policyCRD := range policyManager.GetCRDs() { + shouldSkip := len(args) == 2 && (args[1] != policyCRD.CRD.GetName()) + if shouldSkip { + continue + } + node, err := encodePolicyCRDAsNode(policyCRD) + if err != nil { + return err + } + nodes = append(nodes, node) + } + } + + return o.printNodes(nodes) +} + +func (o *getOptions) printNodes(nodes []*topology.Node) error { + printerOptions := printer.PrinterOptions{ + OutputFormat: o.output, + Clock: clock.RealClock{}, + Description: o.isDescribe, + EventFetcher: printer.NewDefaultEventFetcher(o.factory), + } + p := printer.NewPrinter(printerOptions) + defer p.Flush(o.IOStreams.Out) + for _, node := range topology.SortedNodes(nodes) { + err := p.PrintNode(node, o.IOStreams.Out) + if err != nil { + return err + } + } + return nil +} + +func parseResourceTypeOrNameArgs(args []string) (isPolicy, isPolicyCRD bool, err error) { + if strings.Contains(args[0], ",") { + return false, false, fmt.Errorf("cannot specify more than one type, received types: %v", strings.Split(args[0], ",")) + } + + switch args[0] { + case "policy", "policies": + isPolicy = true + + case "policycrd", "policycrds": + isPolicyCRD = true + } + + return isPolicy, isPolicyCRD, nil +} + +func encodePolicyAsNode(policy *policymanager.Policy) *topology.Node { + return &topology.Node{ + Object: policy.Unstructured, + Metadata: map[string]any{ + common.PolicyGK.String(): policy, + }, + } +} + +func encodePolicyCRDAsNode(policyCRD *policymanager.PolicyCRD) (*topology.Node, error) { + o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(policyCRD.CRD) + if err != nil { + return nil, err + } + u := &unstructured.Unstructured{Object: o} + + return &topology.Node{ + Object: u, + Metadata: map[string]any{ + common.PolicyCRDGK.String(): policyCRD, + }, + }, nil +} diff --git a/cmd/cli/proxy.go b/cmd/cli/proxy.go index ba20780b..b6f856c0 100644 --- a/cmd/cli/proxy.go +++ b/cmd/cli/proxy.go @@ -3,10 +3,10 @@ package main import ( "io" - "sigs.k8s.io/gwctl/pkg/common" - "github.com/spf13/cobra" "helm.sh/helm/v3/pkg/action" + + "github.com/flomesh-io/fsm/pkg/cli/common" ) const proxyCmdDescription = ` diff --git a/cmd/cli/proxy_get.go b/cmd/cli/proxy_get.go index 2dc115c0..87653219 100644 --- a/cmd/cli/proxy_get.go +++ b/cmd/cli/proxy_get.go @@ -5,8 +5,6 @@ import ( "io" "os" - "sigs.k8s.io/gwctl/pkg/common" - "github.com/spf13/cobra" "helm.sh/helm/v3/pkg/action" corev1 "k8s.io/api/core/v1" @@ -14,6 +12,7 @@ import ( "k8s.io/client-go/rest" "github.com/flomesh-io/fsm/pkg/cli" + "github.com/flomesh-io/fsm/pkg/cli/common" "github.com/flomesh-io/fsm/pkg/constants" ) diff --git a/go.mod b/go.mod index 9a3cefb6..995a1ab8 100644 --- a/go.mod +++ b/go.mod @@ -82,6 +82,7 @@ require ( github.com/cilium/ebpf v0.10.0 github.com/containernetworking/cni v1.1.2 github.com/deckarep/golang-set v1.8.0 + github.com/evanphx/json-patch v5.9.0+incompatible github.com/florianl/go-tc v0.4.2 github.com/fsnotify/fsnotify v1.7.0 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 @@ -91,6 +92,7 @@ require ( github.com/go-logr/zerologr v1.2.3 github.com/go-resty/resty/v2 v2.14.0 github.com/gobwas/glob v0.2.3 + github.com/goccy/go-graphviz v0.2.9 github.com/hashicorp/consul/api v1.29.1 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hudl/fargo v1.4.0 @@ -109,7 +111,6 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kubectl v0.31.1 k8s.io/kubernetes v1.31.1 - sigs.k8s.io/gwctl v0.0.0-20240926170801-d8d23c1ba2a6 sigs.k8s.io/yaml v1.4.0 ) @@ -203,6 +204,7 @@ require ( github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc // indirect github.com/digitalocean/godo v1.86.0 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v26.1.5+incompatible // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect @@ -212,10 +214,10 @@ require ( github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/duosecurity/duo_api_golang v0.0.0-20190308151101-6c680f768e74 // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect - github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/flopp/go-findfont v0.1.0 // indirect github.com/fogleman/gg v1.3.0 // indirect github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -253,7 +255,6 @@ require ( github.com/go-toolsmith/strparse v1.0.0 // indirect github.com/go-toolsmith/typep v1.0.2 // indirect github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b // indirect - github.com/goccy/go-graphviz v0.1.3 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -468,6 +469,7 @@ require ( github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2 // indirect github.com/tencentcloud/tencentcloud-sdk-go v1.0.162 // indirect github.com/tetafro/godot v0.4.9 // indirect + github.com/tetratelabs/wazero v1.8.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e // indirect @@ -505,11 +507,11 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.27.0 // indirect - golang.org/x/image v0.18.0 // indirect + golang.org/x/image v0.21.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/text v0.19.0 // indirect golang.org/x/tools v0.24.0 // indirect google.golang.org/api v0.197.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect diff --git a/go.sum b/go.sum index 61e87ac8..e31cb52d 100644 --- a/go.sum +++ b/go.sum @@ -399,8 +399,8 @@ github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20220810130054-c7d1c02cb6cf h1:GOPo6vn/vTN+3IwZBvXX0y5doJfSC7My0cdzelyOCsQ= github.com/coreos/pkg v0.0.0-20220810130054-c7d1c02cb6cf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/corona10/goimagehash v1.0.2 h1:pUfB0LnsJASMPGEZLj7tGY251vF+qLGqOgEP4rUs6kA= -github.com/corona10/goimagehash v1.0.2/go.mod h1:/l9umBhvcHQXVtQO1V6Gp1yD20STawkhRnnX0D1bvVI= +github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= +github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/couchbase/gocb/v2 v2.9.1 h1:yB2ZhRLk782Y9sZlATaUwglZe9+2QpvFmItJXTX4stQ= github.com/couchbase/gocb/v2 v2.9.1/go.mod h1:TMAeK34yUdcASdV4mGcYuwtkAWckRBYN5uvMCEgPfXo= github.com/couchbase/gocbcore/v10 v10.5.1 h1:bwlV/zv/fSQLuO14M9k49K7yWgcWfjSgMyfRGhW1AyU= @@ -451,6 +451,8 @@ github.com/digitalocean/godo v1.86.0/go.mod h1:jELt1jkHVifd0rKaY0pt/m1QxGzbkkvoV github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= @@ -514,6 +516,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= github.com/florianl/go-tc v0.4.2 h1:jan5zcOWCLhA9SRBHZhQ0SSAq7cmDUagiRPngAi5AOQ= github.com/florianl/go-tc v0.4.2/go.mod h1:2W1jSMFryiYlpQigr4ZpSSpE9XNze+bW7cTsCXWbMwo= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= @@ -667,8 +671,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= -github.com/goccy/go-graphviz v0.1.3 h1:Pkt8y4FBnBNI9tfSobpoN5qy1qMNqRXPQYvLhaSUasY= -github.com/goccy/go-graphviz v0.1.3/go.mod h1:pMYpbAqJT10V8dzV1JN/g/wUlG/0imKPzn3ZsrchGCI= +github.com/goccy/go-graphviz v0.2.9 h1:4yD2MIMpxNt+sOEARDh5jTE2S/jeAKi92w72B83mWGg= +github.com/goccy/go-graphviz v0.2.9/go.mod h1:hssjl/qbvUXGmloY81BwXt2nqoApKo7DFgDj5dLJGb8= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gocql/gocql v1.0.0 h1:UnbTERpP72VZ/viKE1Q1gPtmLvyTZTvuAstvSRydw/c= @@ -1506,8 +1510,8 @@ github.com/nakabonne/nestif v0.3.0 h1:+yOViDGhg8ygGrmII72nV9B/zGxY188TYpfolntsaP github.com/nakabonne/nestif v0.3.0/go.mod h1:dI314BppzXjJ4HsCnbo7XzrJHPszZsjnk5wEBSYHI2c= github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d h1:AREM5mwr4u1ORQBMvzfzBgpsctsbQikCVpvC+tX285E= github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= -github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY= -github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicolai86/scaleway-sdk v1.10.2-0.20180628010248-798f60e20bb2 h1:BQ1HW7hr4IVovMwWg0E0PYcyW8CzqDcVmaew9cujU4s= github.com/nicolai86/scaleway-sdk v1.10.2-0.20180628010248-798f60e20bb2/go.mod h1:TLb2Sg7HQcgGdloNxkrmtgDNR9uVYF3lfdFIN4Ro6Sk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -1838,6 +1842,8 @@ github.com/tencentcloud/tencentcloud-sdk-go v1.0.162 h1:8fDzz4GuVg4skjY2B0nMN7h6 github.com/tencentcloud/tencentcloud-sdk-go v1.0.162/go.mod h1:asUz5BPXxgoPGaRgZaVm1iGcUAuHyYUo1nXqKa83cvI= github.com/tetafro/godot v0.4.9 h1:dSOiuasshpevY73eeI3+zaqFnXSBKJ3mvxbyhh54VRo= github.com/tetafro/godot v0.4.9/go.mod h1:/7NLHhv08H1+8DNj0MElpAACw1ajsCuf3TKNQxA5S+0= +github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA550= +github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -2059,8 +2065,9 @@ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQ golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -2383,8 +2390,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2765,8 +2772,6 @@ sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= sigs.k8s.io/gateway-api v1.2.0 h1:LrToiFwtqKTKZcZtoQPTuo3FxhrrhTgzQG0Te+YGSo8= sigs.k8s.io/gateway-api v1.2.0/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= -sigs.k8s.io/gwctl v0.0.0-20240926170801-d8d23c1ba2a6 h1:38b/UpBE1L6ozf9A4CHq3uKQ3rcZsqk0w4tR6gUW8SE= -sigs.k8s.io/gwctl v0.0.0-20240926170801-d8d23c1ba2a6/go.mod h1:X1P8kcjPUdhmc0e88V376wJMuyMJiMaHY/yterL+Sew= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/kind v0.24.0 h1:g4y4eu0qa+SCeKESLpESgMmVFBebL0BDa6f777OIWrg= diff --git a/pkg/cli/common/clients.go b/pkg/cli/common/clients.go new file mode 100644 index 00000000..d95a6fff --- /dev/null +++ b/pkg/cli/common/clients.go @@ -0,0 +1,88 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type GroupKindFetcher interface { + Fetch(gk schema.GroupKind) ([]*unstructured.Unstructured, error) +} + +var _ GroupKindFetcher = (*defaultGroupKindFetcher)(nil) + +type defaultGroupKindFetcher struct { + factory Factory + additionalResourcesByGK map[schema.GroupKind][]*unstructured.Unstructured +} + +type groupKindFetcherOption func(*defaultGroupKindFetcher) + +func WithAdditionalResources(resources []*unstructured.Unstructured) groupKindFetcherOption { //nolint:revive + return func(f *defaultGroupKindFetcher) { + for _, resource := range resources { + gk := resource.GetObjectKind().GroupVersionKind().GroupKind() + f.additionalResourcesByGK[gk] = append(f.additionalResourcesByGK[gk], resource) + } + } +} + +func NewDefaultGroupKindFetcher(factory Factory, options ...groupKindFetcherOption) *defaultGroupKindFetcher { //nolint:revive + d := &defaultGroupKindFetcher{ + factory: factory, + additionalResourcesByGK: make(map[schema.GroupKind][]*unstructured.Unstructured), + } + for _, option := range options { + option(d) + } + return d +} + +func (d defaultGroupKindFetcher) Fetch(gk schema.GroupKind) ([]*unstructured.Unstructured, error) { + infos, err := d.factory.NewBuilder(). + Unstructured(). + Flatten(). + AllNamespaces(true). + ResourceTypeOrNameArgs(true, []string{fmt.Sprintf("%v.%v", gk.Kind, gk.Group)}...). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return nil, err + } + + var result []*unstructured.Unstructured + for _, info := range infos { + o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) + if err != nil { + return nil, err + } + result = append(result, &unstructured.Unstructured{Object: o}) + } + + // Return any additional Resources if they have been provided. + //for _, u := range d.additionalResourcesByGK[gk] { + result = append(result, d.additionalResourcesByGK[gk]...) + //} + + return result, nil +} diff --git a/pkg/cli/common/errors.go b/pkg/cli/common/errors.go new file mode 100644 index 00000000..a98600a9 --- /dev/null +++ b/pkg/cli/common/errors.go @@ -0,0 +1,82 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "fmt" +) + +type ReferenceToNonExistentResourceError struct { + ReferenceFromTo +} + +func (r ReferenceToNonExistentResourceError) Error() string { + return fmt.Sprintf("%v %q references a non-existent %v %q", + r.referringObjectKind(), r.referringObjectName(), + r.referredObjectKind(), r.referredObjectName()) +} + +type ReferenceNotPermittedError struct { + ReferenceFromTo +} + +func (r ReferenceNotPermittedError) Error() string { + return fmt.Sprintf("%v %q is not permitted to reference %v %q", + r.referringObjectKind(), r.referringObjectName(), + r.referredObjectKind(), r.referredObjectName()) +} + +type ReferenceFromTo struct { + // ReferringObject is the "from" object which is referring "to" some other + // object. + ReferringObject GKNN + // ReferredObject is the actual object which is being referenced by another + // object. + ReferredObject GKNN +} + +// referringObjectKind returns a human readable Kind. +func (r ReferenceFromTo) referringObjectKind() string { + if r.ReferringObject.Group != "" { + return fmt.Sprintf("%v(.%v)", r.ReferringObject.Kind, r.ReferringObject.Group) + } + return r.ReferringObject.Kind +} + +// referredObjectKind returns a human readable Kind. +func (r ReferenceFromTo) referredObjectKind() string { + if r.ReferredObject.Group != "" { + return fmt.Sprintf("%v(.%v)", r.ReferredObject.Kind, r.ReferredObject.Group) + } + return r.ReferredObject.Kind +} + +// referringObjectName returns a human readable Name. +func (r ReferenceFromTo) referringObjectName() string { + if r.ReferringObject.Namespace != "" { + return fmt.Sprintf("%v/%v", r.ReferringObject.Namespace, r.ReferringObject.Name) + } + return r.ReferringObject.Name +} + +// referredObjectName returns a human readable Name. +func (r ReferenceFromTo) referredObjectName() string { + if r.ReferredObject.Namespace != "" { + return fmt.Sprintf("%v/%v", r.ReferredObject.Namespace, r.ReferredObject.Name) + } + return r.ReferredObject.Name +} diff --git a/pkg/cli/common/factory.go b/pkg/cli/common/factory.go new file mode 100644 index 00000000..99629afa --- /dev/null +++ b/pkg/cli/common/factory.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" +) + +type Factory interface { + NewBuilder() *resource.Builder + KubeConfigNamespace() (string, bool, error) +} + +type factoryImpl struct { + clientGetter genericclioptions.RESTClientGetter +} + +func NewFactory(clientGetter genericclioptions.RESTClientGetter) Factory { + return &factoryImpl{clientGetter: clientGetter} +} + +func (f *factoryImpl) NewBuilder() *resource.Builder { + return resource.NewBuilder(f.clientGetter) +} + +func (f *factoryImpl) KubeConfigNamespace() (string, bool, error) { + return f.clientGetter.ToRawKubeConfigLoader().Namespace() +} diff --git a/pkg/cli/common/testhelpers.go b/pkg/cli/common/testhelpers.go new file mode 100644 index 00000000..51a3af56 --- /dev/null +++ b/pkg/cli/common/testhelpers.go @@ -0,0 +1,86 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// MultiLine defines a custom type for wrapping texts spanning multilpe lines. +// It makes use of MultiLineTransformer to generate slightly better diffing +// output from cmp.Diff() for multi-line texts. +type MultiLine string + +// MultiLineTransformer transforms a MultiLine into a slice of strings by +// splitting on each new line. This allows the diffing function (used in tests) +// to compare each line independently. The result is that the diff output marks +// each line where a diff was observed. +var MultiLineTransformer = cmp.Transformer("MultiLine", func(m MultiLine) []string { + return strings.Split(string(m), "\n") +}) + +const ( + beginMarker = "#################################### BEGIN #####################################" + endMarker = "##################################### END ######################################" +) + +func (m MultiLine) String() string { + return fmt.Sprintf("%v\n%v%v", beginMarker, string(m), endMarker) +} + +type JSONString string + +func (src JSONString) CmpDiff(tgt JSONString) (diff string, err error) { + var srcMap, targetMap map[string]interface{} + err = json.Unmarshal([]byte(src), &srcMap) + if err != nil { + err = fmt.Errorf("failed to unmarshal the source json: %w", err) + return + } + err = json.Unmarshal([]byte(tgt), &targetMap) + if err != nil { + err = fmt.Errorf("failed to unmarshal the target json: %w", err) + return + } + + return cmp.Diff(srcMap, targetMap), nil +} + +func NamespaceForTest(name string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Status: corev1.NamespaceStatus{ + Phase: corev1.NamespaceActive, + }, + } +} + +func MustPrettyPrint(data any) { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + panic(err) + } + fmt.Println(string(b)) +} diff --git a/pkg/cli/common/types.go b/pkg/cli/common/types.go new file mode 100644 index 00000000..a8267ece --- /dev/null +++ b/pkg/cli/common/types.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) + +const ( + gwctlPolicyGroup = "gwctl.gateway.networking.k8s.io" +) + +var ( + GatewayClassGK schema.GroupKind = schema.GroupKind{Group: gatewayv1.GroupName, Kind: "GatewayClass"} + GatewayGK schema.GroupKind = schema.GroupKind{Group: gatewayv1.GroupName, Kind: "Gateway"} + HTTPRouteGK schema.GroupKind = schema.GroupKind{Group: gatewayv1.GroupName, Kind: "HTTPRoute"} + NamespaceGK schema.GroupKind = schema.GroupKind{Group: corev1.GroupName, Kind: "Namespace"} + ServiceGK schema.GroupKind = schema.GroupKind{Group: corev1.GroupName, Kind: "Service"} + ReferenceGrantGK schema.GroupKind = schema.GroupKind{Group: gatewayv1beta1.GroupName, Kind: "ReferenceGrant"} + PolicyGK schema.GroupKind = schema.GroupKind{Group: gwctlPolicyGroup, Kind: "Policy"} + PolicyCRDGK schema.GroupKind = schema.GroupKind{Group: gwctlPolicyGroup, Kind: "PolicyCRD"} +) + +type GKNN struct { + Group string `json:",omitempty"` + Kind string `json:",omitempty"` + Namespace string `json:",omitempty"` + Name string `json:",omitempty"` +} + +func (g GKNN) GroupKind() schema.GroupKind { + return schema.GroupKind{ + Group: g.Group, + Kind: g.Kind, + } +} + +func (g GKNN) NamespacedName() types.NamespacedName { + return types.NamespacedName{ + Namespace: g.Namespace, + Name: g.Name, + } +} + +func (g GKNN) String() string { + gk := g.Kind + if g.Group != "" { + gk = fmt.Sprintf("%v.%v", g.Kind, g.Group) + } + name := g.Name + if g.Namespace != "" { + name = fmt.Sprintf("%v/%v", g.Namespace, g.Name) + } + return gk + "/" + name +} + +func (g GKNN) MarshalText() ([]byte, error) { + return []byte(g.String()), nil +} + +func GKNNFromUnstructured(u *unstructured.Unstructured) GKNN { + return GKNN{ + Group: u.GetObjectKind().GroupVersionKind().Group, + Kind: u.GetObjectKind().GroupVersionKind().Kind, + Namespace: u.GetNamespace(), + Name: u.GetName(), + } +} diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 3671b6ef..06d9c106 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -48,13 +48,13 @@ import ( "path" "path/filepath" - "sigs.k8s.io/gwctl/pkg/common" - "k8s.io/cli-runtime/pkg/genericiooptions" "github.com/spf13/pflag" "gopkg.in/yaml.v2" "k8s.io/cli-runtime/pkg/genericclioptions" + + "github.com/flomesh-io/fsm/pkg/cli/common" ) const ( diff --git a/pkg/cli/extension/directlyattachedpolicy/directlyattachedpolicy.go b/pkg/cli/extension/directlyattachedpolicy/directlyattachedpolicy.go new file mode 100644 index 00000000..b4d8ce39 --- /dev/null +++ b/pkg/cli/extension/directlyattachedpolicy/directlyattachedpolicy.go @@ -0,0 +1,80 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package directlyattachedpolicy + +import ( + "fmt" + + "k8s.io/klog/v2" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +const ( + extensionName = "DirectlyAttachedPolicy" +) + +type Extension struct { + policyManager *policymanager.PolicyManager +} + +func NewExtension(policyManager *policymanager.PolicyManager) *Extension { + return &Extension{policyManager: policyManager} +} + +func (a *Extension) Execute(graph *topology.Graph) error { + graph.RemoveMetadata(extensionName) + for _, policy := range a.policyManager.GetPolicies() { + gk := policy.TargetRef.GroupKind() + nn := policy.TargetRef.NamespacedName() + + if graph.Nodes[gk] == nil || graph.Nodes[gk][nn] == nil { + // This target doesn't exist in the graph, so skip the policy. + continue + } + + node := graph.Nodes[gk][nn] + if node.Metadata == nil { + node.Metadata = map[string]any{} + } + if node.Metadata[extensionName] == nil { + node.Metadata[extensionName] = map[common.GKNN]*policymanager.Policy{} + } + + data, err := Access(node) + if err != nil { + return err + } + data[policy.GKNN()] = policy + } + return nil +} + +func Access(node *topology.Node) (map[common.GKNN]*policymanager.Policy, error) { + rawData, ok := node.Metadata[extensionName] + if !ok || rawData == nil { + klog.V(3).InfoS(fmt.Sprintf("no data found in node for %v", extensionName), "node", node.GKNN()) + return nil, nil + } + data, ok := rawData.(map[common.GKNN]*policymanager.Policy) + if !ok { + return nil, fmt.Errorf("unable to perform type assertion for %v in node %v", extensionName, node.GKNN()) + } + return data, nil +} diff --git a/pkg/cli/extension/extensions.go b/pkg/cli/extension/extensions.go new file mode 100644 index 00000000..8dfdd1c5 --- /dev/null +++ b/pkg/cli/extension/extensions.go @@ -0,0 +1,44 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package extension + +import "github.com/flomesh-io/fsm/pkg/cli/topology" + +type Extension interface { + Execute(*topology.Graph) error +} + +// TODO: Scope of improvement in the future involves: +// - Making executions parallel, when there are blocking operations. +// - Defining dependent extensions to determine their relative order. +func ExecuteAll(graph *topology.Graph, extensions ...Extension) error { + for _, nodes := range graph.Nodes { + for _, node := range nodes { + if node.Metadata == nil { + node.Metadata = make(map[string]any) + } + } + } + + for _, extension := range extensions { + err := extension.Execute(graph) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/cli/extension/gatewayeffectivepolicy/gatewayeffectivepolicy.go b/pkg/cli/extension/gatewayeffectivepolicy/gatewayeffectivepolicy.go new file mode 100644 index 00000000..089f059a --- /dev/null +++ b/pkg/cli/extension/gatewayeffectivepolicy/gatewayeffectivepolicy.go @@ -0,0 +1,472 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gatewayeffectivepolicy + +import ( + "fmt" + "maps" + + "k8s.io/klog/v2" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +const ( + extensionName = "InheritedPolicy" +) + +type Extension struct{} + +func NewExtension() *Extension { + return &Extension{} +} + +// Extension calculates the effective policies for all Gateways, HTTPRoutes, and +// Backends in the Graph. +func (a *Extension) Execute(graph *topology.Graph) error { + graph.RemoveMetadata(extensionName) + if err := a.calculateInheritedPolicies(graph); err != nil { + return err + } + return a.calculateEffectivePolicies(graph) +} + +// calculateInheritedPolicies calculates the inherited polices for all Gateways, +// HTTRoutes, and Backends in the Graph. +func (a *Extension) calculateInheritedPolicies(graph *topology.Graph) error { + if err := a.calculateInheritedPoliciesForGateways(graph); err != nil { + return err + } + if err := a.calculateInheritedPoliciesForHTTPRoutes(graph); err != nil { + return err + } + if err := a.calculateInheritedPoliciesForBackends(graph); err != nil { + return err + } + return nil +} + +// calculateInheritedPoliciesForGateways calculates the inherited policies for +// all Gateways present in the Graph. +func (a *Extension) calculateInheritedPoliciesForGateways(graph *topology.Graph) error { + for _, gatewayNode := range graph.Nodes[common.GatewayGK] { + result := make(map[common.GKNN]*policymanager.Policy) + + // Policies inherited from Gateway's namespace. + namespaceNode := topologygw.GatewayNode(gatewayNode).Namespace() + if namespaceNode != nil { + namespacePoliciesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + maps.Copy(result, filterInheritablePolicies(namespacePoliciesMap)) + } + + // Policies inherited from GatewayClass. + gatewayClassNode := topologygw.GatewayNode(gatewayNode).GatewayClass() + if gatewayClassNode != nil { + gatewayClassPoliciesMap, err := directlyattachedpolicy.Access(gatewayClassNode) + if err != nil { + return err + } + maps.Copy(result, filterInheritablePolicies(gatewayClassPoliciesMap)) + } + + gatewayNode.Metadata[extensionName] = &NodeMetadata{GatewayInheritedPolicies: result} + } + return nil +} + +// calculateInheritedPoliciesForHTTPRoutes calculates the inherited policies for +// all HTTPRoutes present in the Graph. +func (a *Extension) calculateInheritedPoliciesForHTTPRoutes(graph *topology.Graph) error { + for _, httpRouteNode := range graph.Nodes[common.HTTPRouteGK] { + result := make(map[common.GKNN]*policymanager.Policy) + + // Policies inherited from HTTPRoute's namespace. + namespaceNode := topologygw.HTTPRouteNode(httpRouteNode).Namespace() + if namespaceNode != nil { + namespacePoliciesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + maps.Copy(result, filterInheritablePolicies(namespacePoliciesMap)) + } + + // Policies inherited from Gateways. + gatewayNodes := topologygw.HTTPRouteNode(httpRouteNode).Gateways() + //if gatewayNodes != nil { + for _, gatewayNode := range gatewayNodes { + // Add policies inherited by GatewayNode. + effPolicyMetadata, err := Access(gatewayNode) + if err != nil { + return err + } + if effPolicyMetadata != nil { + maps.Copy(result, effPolicyMetadata.GatewayInheritedPolicies) + } + + // Add inheritable policies directly applied to GatewayNode. + gatewayPoliciesMap, err := directlyattachedpolicy.Access(gatewayNode) + if err != nil { + return err + } + maps.Copy(result, filterInheritablePolicies(gatewayPoliciesMap)) + } + //} + + httpRouteNode.Metadata[extensionName] = &NodeMetadata{HTTPRouteInheritedPolicies: result} + } + return nil +} + +// calculateInheritedPoliciesForBackends calculates the inherited policies for +// all Backends present in ResourceModel. +func (a *Extension) calculateInheritedPoliciesForBackends(graph *topology.Graph) error { + for _, backendNode := range graph.Nodes[common.ServiceGK] { + result := make(map[common.GKNN]*policymanager.Policy) + + // Policies inherited from Backend's namespace. + namespaceNode := topologygw.BackendNode(backendNode).Namespace() + if namespaceNode != nil { + namespacePoliciesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + maps.Copy(result, filterInheritablePolicies(namespacePoliciesMap)) + } + + // Policies inherited from HTTPRoutes. + httpRouteNodes := topologygw.BackendNode(backendNode).HTTPRoutes() + //if httpRouteNodes != nil { + for _, httpRouteNode := range httpRouteNodes { + // Add policies inherited by HTTPRouteNode. + effPolicyMetadata, err := Access(httpRouteNode) + if err != nil { + return err + } + if effPolicyMetadata != nil { + maps.Copy(result, effPolicyMetadata.HTTPRouteInheritedPolicies) + } + + // Add inheritable policies directly applied to HTTPRouteNode. + httpRoutePoliciesMap, err := directlyattachedpolicy.Access(httpRouteNode) + if err != nil { + return err + } + maps.Copy(result, filterInheritablePolicies(httpRoutePoliciesMap)) + } + //} + + backendNode.Metadata[extensionName] = &NodeMetadata{BackendInheritedPolicies: result} + } + return nil +} + +// filterInheritablePolicies filters and returns policies which can be inherited. +func filterInheritablePolicies(policies map[common.GKNN]*policymanager.Policy) map[common.GKNN]*policymanager.Policy { + inheritablePolicies := make(map[common.GKNN]*policymanager.Policy) + + for gknn, policy := range policies { + if policy.IsInheritable() { + inheritablePolicies[gknn] = policy + } + } + + return inheritablePolicies +} + +func (a *Extension) calculateEffectivePolicies(graph *topology.Graph) error { + if err := a.calculateEffectivePoliciesForGateways(graph); err != nil { + return err + } + if err := a.calculateEffectivePoliciesForHTTPRoutes(graph); err != nil { + return err + } + if err := a.calculateEffectivePoliciesForBackends(graph); err != nil { + return err + } + return nil +} + +// calculateEffectivePoliciesForGateways calculates the effective policies for +// each Gateway by merging policies from different hierarchies (GatewayClass, +// Namespace, and Gateway). +func (a *Extension) calculateEffectivePoliciesForGateways(graph *topology.Graph) error { + for _, gatewayNode := range graph.Nodes[common.GatewayGK] { + if gatewayNode.Depth > graph.MaxDepth { + continue + } + + gatewayClassNode := topologygw.GatewayNode(gatewayNode).GatewayClass() + if gatewayClassNode == nil { + klog.V(3).InfoS("No GatewayClass node found for Gateway, skipping effective policy calculation", "gateway", gatewayNode.GKNN()) + continue + } + namespaceNode := topologygw.GatewayNode(gatewayNode).Namespace() + if namespaceNode == nil { + klog.V(3).InfoS("No Namespace node found for Gateway, skipping effective policy calculation", "gateway", gatewayNode.GKNN()) + continue + } + + gatewayClassPoliciesMap, err := directlyattachedpolicy.Access(gatewayClassNode) + if err != nil { + return err + } + namespacePoliciesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + gatewayPoliciesMap, err := directlyattachedpolicy.Access(gatewayNode) + if err != nil { + return err + } + + // Do not calculate effective policy for the Gateway if the referenced + // GatewayClass does not exist. For now, we only calculate effective policy + // once the references are corrected. + if gatewayClassNode == nil { + continue + } + + // Fetch all policies. + gatewayClassPolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(gatewayClassPoliciesMap)) + gatewayNamespacePolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(namespacePoliciesMap)) + gatewayPolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(gatewayPoliciesMap)) + + // Merge policies by their kind. + gatewayClassPoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(gatewayClassPolicies) + if err != nil { + return err + } + gatewayNamespacePoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(gatewayNamespacePolicies) + if err != nil { + return err + } + gatewayPoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(gatewayPolicies) + if err != nil { + return err + } + + // Merge all hierarchial policies. + result, err := policymanager.MergePoliciesOfDifferentHierarchy(gatewayClassPoliciesByKind, gatewayNamespacePoliciesByKind) + if err != nil { + return err + } + + result, err = policymanager.MergePoliciesOfDifferentHierarchy(result, gatewayPoliciesByKind) + if err != nil { + return err + } + + gatewayNodeMetadata, err := Access(gatewayNode) + if err != nil { + return err + } + if gatewayNodeMetadata == nil { + gatewayNodeMetadata = &NodeMetadata{} + gatewayNode.Metadata[extensionName] = gatewayNodeMetadata + } + gatewayNodeMetadata.GatewayEffectivePolicies = result + } + return nil +} + +// calculateEffectivePoliciesForHTTPRoutes calculates the effective policies for +// each HTTPRoute, taking into account policies from different hierarchies +// (GatewayClass, Namespace, Gateway, and HTTPRoute). +func (a *Extension) calculateEffectivePoliciesForHTTPRoutes(graph *topology.Graph) error { + for _, httpRouteNode := range graph.Nodes[common.HTTPRouteGK] { + result := make(map[common.GKNN]map[policymanager.PolicyCrdID]*policymanager.Policy) + + namespaceNode := topologygw.HTTPRouteNode(httpRouteNode).Namespace() + if namespaceNode == nil { + klog.V(3).InfoS("No Namespace node found for HTTPRoute, skipping effective policy calculation", "httpRoute", httpRouteNode.GKNN()) + continue + } + + httpRoutePoliciesMap, err := directlyattachedpolicy.Access(httpRouteNode) + if err != nil { + return err + } + namespacePoliciesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + + // Step 1: Aggregate all policies of the HTTPRoute and the + // HTTPRoute-namespace. + httpRoutePolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(httpRoutePoliciesMap)) + httpRouteNamespacePolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(namespacePoliciesMap)) + + // Step 2: Merge HTTPRoute and HTTPRoute-namespace policies by their kind. + httpRoutePoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(httpRoutePolicies) + if err != nil { + return err + } + httpRouteNamespacePoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(httpRouteNamespacePolicies) + if err != nil { + return err + } + + // Step 3: Loop through all Gateways and merge policies for each Gateway. + // End result is we get policies partitioned by each Gateway. + for gatewayGKNN, gatewayNode := range topologygw.HTTPRouteNode(httpRouteNode).Gateways() { + gatewayNodeMetadata, err := Access(gatewayNode) //nolint:govet + if err != nil { + return err + } + gatewayPoliciesByKind := gatewayNodeMetadata.GatewayEffectivePolicies + + // Merge all hierarchial policies. + mergedPolicies, err := policymanager.MergePoliciesOfDifferentHierarchy(gatewayPoliciesByKind, httpRouteNamespacePoliciesByKind) + if err != nil { + return err + } + + mergedPolicies, err = policymanager.MergePoliciesOfDifferentHierarchy(mergedPolicies, httpRoutePoliciesByKind) + if err != nil { + return err + } + + result[gatewayGKNN] = mergedPolicies + } + + httpRouteNodeMetadata, err := Access(httpRouteNode) + if err != nil { + return err + } + if httpRouteNodeMetadata == nil { + httpRouteNodeMetadata = &NodeMetadata{} + httpRouteNode.Metadata[extensionName] = httpRouteNodeMetadata + } + httpRouteNodeMetadata.HTTPRouteEffectivePolicies = result + } + return nil +} + +// calculateEffectivePoliciesForBackends calculates the effective policies for +// each Backend, considering policies from different hierarchies (GatewayClass, +// Namespace, Gateway, HTTPRoute, and Backend). +func (a *Extension) calculateEffectivePoliciesForBackends(graph *topology.Graph) error { + for _, backendNode := range graph.Nodes[common.ServiceGK] { + result := make(map[common.GKNN]map[policymanager.PolicyCrdID]*policymanager.Policy) + + namespaceNode := topologygw.BackendNode(backendNode).Namespace() + if namespaceNode == nil { + klog.V(3).InfoS("No Namespace node found for Backend, skipping effective policy calculation", "backend", backendNode.GKNN()) + continue + } + + backendPoliciesMap, err := directlyattachedpolicy.Access(backendNode) + if err != nil { + return err + } + namespacePoliciesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + + // Step 1: Aggregate all policies of the Backend and the Backend-namespace. + backendPolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(backendPoliciesMap)) + backendNamespacePolicies := policymanager.ConvertPoliciesMapToSlice(filterInheritablePolicies(namespacePoliciesMap)) + + // Step 2: Merge Backend and Backend-namespace policies by their kind. + backendPoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(backendPolicies) + if err != nil { + return err + } + backendNamespacePoliciesByKind, err := policymanager.MergePoliciesOfSimilarKind(backendNamespacePolicies) + if err != nil { + return err + } + + // Step 3: Loop through all HTTPRoutes and get their effective policies. Merge + // effective policies such that we get policies partitioned by Gateway. + for _, httpRouteNode := range topologygw.BackendNode(backendNode).HTTPRoutes() { + httpRouteNodeMetadata, err := Access(httpRouteNode) //nolint:govet + if err != nil { + return err + } + httpRoutePoliciesByGateway := httpRouteNodeMetadata.HTTPRouteEffectivePolicies + + for gatewayID, policies := range httpRoutePoliciesByGateway { + result[gatewayID], err = policymanager.MergePoliciesOfSameHierarchy(result[gatewayID], policies) + if err != nil { + return err + } + } + } + + // Step 4: Loop through all Gateways and merge the Backend and + // Backend-namespace specific policies. Note that this needs to be done + // separately from Step 4 i.e. we can't have this loop within Step 4 itself. + // This is because we first want to merge all policies of the same-hierarchy + // together and then move to the next hierarchy of Backend and + // Backend-namespace. + for gatewayID := range result { + // Merge all hierarchial policies. + result[gatewayID], err = policymanager.MergePoliciesOfDifferentHierarchy(result[gatewayID], backendNamespacePoliciesByKind) + if err != nil { + return err + } + + result[gatewayID], err = policymanager.MergePoliciesOfDifferentHierarchy(result[gatewayID], backendPoliciesByKind) + if err != nil { + return err + } + } + + backendNodeMetadata, err := Access(backendNode) + if err != nil { + return err + } + if backendNodeMetadata == nil { + backendNodeMetadata = &NodeMetadata{} + backendNode.Metadata[extensionName] = backendNodeMetadata + } + backendNodeMetadata.BackendEffectivePolicies = result + } + return nil +} + +type NodeMetadata struct { + GatewayInheritedPolicies map[common.GKNN]*policymanager.Policy + HTTPRouteInheritedPolicies map[common.GKNN]*policymanager.Policy + BackendInheritedPolicies map[common.GKNN]*policymanager.Policy + + GatewayEffectivePolicies map[policymanager.PolicyCrdID]*policymanager.Policy + HTTPRouteEffectivePolicies map[common.GKNN]map[policymanager.PolicyCrdID]*policymanager.Policy + BackendEffectivePolicies map[common.GKNN]map[policymanager.PolicyCrdID]*policymanager.Policy +} + +func Access(node *topology.Node) (*NodeMetadata, error) { + rawData, ok := node.Metadata[extensionName] + if !ok || rawData == nil { + klog.V(3).InfoS(fmt.Sprintf("no data found in node for %v", extensionName), "node", node.GKNN()) + return nil, nil + } + data, ok := rawData.(*NodeMetadata) + if !ok { + return nil, fmt.Errorf("unable to perform type assertion for %v in node %v", extensionName, node.GKNN()) + } + return data, nil +} diff --git a/pkg/cli/extension/notfoundrefvalidator/notfoundrefvalidator.go b/pkg/cli/extension/notfoundrefvalidator/notfoundrefvalidator.go new file mode 100644 index 00000000..35877bba --- /dev/null +++ b/pkg/cli/extension/notfoundrefvalidator/notfoundrefvalidator.go @@ -0,0 +1,107 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notfoundrefvalidator + +import ( + "fmt" + + "k8s.io/klog/v2" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +const ( + extensionName = "NotFoundReferenceValidator" +) + +type Extension struct{} + +func NewExtension() *Extension { + return &Extension{} +} + +func (a *Extension) Execute(graph *topology.Graph) error { + graph.RemoveMetadata(extensionName) + for _, relation := range graph.Relations { + for _, fromNode := range graph.Nodes[relation.From] { + if fromNode.Depth > graph.MaxDepth { + klog.V(3).InfoS("Not validating resource since it's depth is greater than the max depth", + "extension", extensionName, "resource", fromNode.GKNN(), "depth", fromNode.Depth, "MaxDepth", graph.MaxDepth, + ) + } + + for _, toNodeGKNN := range relation.NeighborFunc(fromNode.Object) { + err := common.ReferenceToNonExistentResourceError{ReferenceFromTo: common.ReferenceFromTo{ + ReferringObject: fromNode.GKNN(), + ReferredObject: toNodeGKNN, + }} + + if _, ok := graph.Nodes[toNodeGKNN.GroupKind()]; !ok { + if err := a.puErrorInNode(fromNode, err); err != nil { + return err + } + klog.V(1).Info(err) + continue + } + toNode := graph.Nodes[toNodeGKNN.GroupKind()][toNodeGKNN.NamespacedName()] + if toNode == nil { + if err := a.puErrorInNode(fromNode, err); err != nil { + return err + } + klog.V(1).Info(err) + } + } + } + } + return nil +} + +func (a *Extension) puErrorInNode(node *topology.Node, notFoundErr error) error { + if node.Metadata == nil { + node.Metadata = map[string]any{} + } + if node.Metadata[extensionName] == nil { + node.Metadata[extensionName] = &NodeMetadata{ + Errors: make([]error, 0), + } + } + + data, err := Access(node) + if err != nil { + return err + } + data.Errors = append(data.Errors, notFoundErr) + return nil +} + +type NodeMetadata struct { + Errors []error +} + +func Access(node *topology.Node) (*NodeMetadata, error) { + rawData, ok := node.Metadata[extensionName] + if !ok || rawData == nil { + klog.V(3).InfoS(fmt.Sprintf("no data found in node for %v", extensionName), "node", node.GKNN()) + return nil, nil + } + data, ok := rawData.(*NodeMetadata) + if !ok { + return nil, fmt.Errorf("unable to perform type assertion for %v in node %v", extensionName, node.GKNN()) + } + return data, nil +} diff --git a/pkg/cli/extension/refgrantvalidator/refgrantvalidator.go b/pkg/cli/extension/refgrantvalidator/refgrantvalidator.go new file mode 100644 index 00000000..846f9137 --- /dev/null +++ b/pkg/cli/extension/refgrantvalidator/refgrantvalidator.go @@ -0,0 +1,303 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package refgrantvalidator + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +const ( + extensionName = "ReferenceGrantsForBackend" +) + +type Extension struct { + fetcher referenceGrantFetcher +} + +func NewExtension(fetcher referenceGrantFetcher) *Extension { + return &Extension{fetcher: fetcher} +} + +// Extension calculates the effective policies for all Gateways, HTTPRoutes, and +// Backends in the Graph. +func (a *Extension) Execute(graph *topology.Graph) error { + graph.RemoveMetadata(extensionName) + if err := a.discoverReferenceGrantsForBackends(graph); err != nil { + return err + } + return a.validateHTTPRoutes(graph) +} + +func (a *Extension) discoverReferenceGrantsForBackends(graph *topology.Graph) error { + referenceGrantsByNamespace := make(map[string][]*gatewayv1beta1.ReferenceGrant) + for _, backendNode := range graph.Nodes[common.ServiceGK] { + backendNS := backendNode.Object.GetNamespace() + + referenceGrants, ok := referenceGrantsByNamespace[backendNS] + if !ok { + var err error + referenceGrants, err = a.fetcher.FetchReferenceGrantsForNamespace(backendNS) + if err != nil { + return err + } + referenceGrantsByNamespace[backendNS] = referenceGrants + } + + for _, referenceGrant := range referenceGrants { + backendRef := backendNode.GKNN() + if ReferenceGrantExposes(referenceGrant, backendRef) { + klog.V(1).InfoS("ReferenceGrant exposes Backend", + "referenceGrant", referenceGrant.GetNamespace()+"/"+referenceGrant.GetName(), + "backendRef", backendRef.Namespace+"/"+backendRef.Name, + ) + if err := a.putReferenceGrantInNode(backendNode, referenceGrant); err != nil { + return err + } + } + } + } + return nil +} + +func (a *Extension) validateHTTPRoutes(graph *topology.Graph) error { + for _, httpRouteNode := range graph.Nodes[common.HTTPRouteGK] { + if httpRouteNode.Depth > graph.MaxDepth { + klog.V(3).InfoS("Not validating HTTPRoute since it's depth is greater than the max depth", + "extension", extensionName, "httpRouteNode.Depth", httpRouteNode.Depth, "MaxDepth", graph.MaxDepth, + ) + continue + } + + for backendGKNN, backendNode := range topologygw.HTTPRouteNode(httpRouteNode).Backends() { + // Ensure that if this is a cross namespace reference, then it is accepted + // through some ReferenceGrant. + if httpRouteNode.GKNN().Namespace != backendGKNN.Namespace { + backendNodeMetadata, err := Access(backendNode) + if err != nil { + return err + } + + var referenceAccepted bool + if backendNodeMetadata != nil { + for _, referenceGrant := range backendNodeMetadata.ReferenceGrants { + if ReferenceGrantAccepts(referenceGrant, httpRouteNode.GKNN()) { + referenceAccepted = true + break + } + } + } + if !referenceAccepted { + err := common.ReferenceNotPermittedError{ReferenceFromTo: common.ReferenceFromTo{ + ReferringObject: httpRouteNode.GKNN(), + ReferredObject: backendGKNN, + }} + if err := a.putReferenceGrantErrorInNode(httpRouteNode, err); err != nil { + return err + } + klog.V(1).InfoS("Reference not permitted", "from", httpRouteNode.GKNN(), "to", backendGKNN) + continue + } + } + } + } + return nil +} + +func (a *Extension) putReferenceGrantInNode(node *topology.Node, referenceGrant *gatewayv1beta1.ReferenceGrant) error { + if node.Metadata == nil { + node.Metadata = map[string]any{} + } + if node.Metadata[extensionName] == nil { + node.Metadata[extensionName] = &NodeMetadata{ + ReferenceGrants: make(map[common.GKNN]*gatewayv1beta1.ReferenceGrant), + Errors: make([]error, 0), + } + } + + data, err := Access(node) + if err != nil { + return err + } + gknn := common.GKNN{ + Group: common.ReferenceGrantGK.Group, + Kind: common.ReferenceGrantGK.Kind, + Namespace: referenceGrant.GetNamespace(), + Name: referenceGrant.GetName(), + } + data.ReferenceGrants[gknn] = referenceGrant + return nil +} + +func (a *Extension) putReferenceGrantErrorInNode(node *topology.Node, refGrantErr error) error { + if node.Metadata == nil { + node.Metadata = map[string]any{} + } + if node.Metadata[extensionName] == nil { + node.Metadata[extensionName] = &NodeMetadata{ + ReferenceGrants: make(map[common.GKNN]*gatewayv1beta1.ReferenceGrant), + Errors: make([]error, 0), + } + } + + data, err := Access(node) + if err != nil { + return err + } + data.Errors = append(data.Errors, refGrantErr) + return nil +} + +type NodeMetadata struct { + ReferenceGrants map[common.GKNN]*gatewayv1beta1.ReferenceGrant + Errors []error +} + +func Access(node *topology.Node) (*NodeMetadata, error) { + rawData, ok := node.Metadata[extensionName] + if !ok || rawData == nil { + klog.V(3).InfoS(fmt.Sprintf("no data found in node for %v", extensionName), "node", node.GKNN()) + return nil, nil + } + data, ok := rawData.(*NodeMetadata) + if !ok { + return nil, fmt.Errorf("unable to perform type assertion for %v in node %v", extensionName, node.GKNN()) + } + return data, nil +} + +// ReferenceGrantExposes returns true if the provided reference grant "exposes" +// the given resource. "Exposes" means that the resource is part of the "To" +// fields within the ReferenceGrant. +func ReferenceGrantExposes(referenceGrant *gatewayv1beta1.ReferenceGrant, resource common.GKNN) bool { + if referenceGrant.GetNamespace() != resource.Namespace { + return false + } + for _, to := range referenceGrant.Spec.To { + if to.Group != gatewayv1.Group(resource.Group) { + continue + } + if to.Kind != gatewayv1.Kind(resource.Kind) { + continue + } + if to.Name == nil || len(*to.Name) == 0 || *to.Name == gatewayv1.ObjectName(resource.Name) { + return true + } + } + return false +} + +// ReferenceGrantAccepts returns true if the provided reference grant "accepts" +// references from the given resource. "Accepts" means that the resource is part +// of the "From" fields within the ReferenceGrant. +func ReferenceGrantAccepts(referenceGrant *gatewayv1beta1.ReferenceGrant, resource common.GKNN) bool { + resource.Name = "" + for _, from := range referenceGrant.Spec.From { + fromRef := common.GKNN{ + Group: string(from.Group), + Kind: string(from.Kind), + Namespace: string(from.Namespace), + } + if fromRef == resource { + return true + } + } + return false +} + +type referenceGrantFetcher interface { + FetchReferenceGrantsForNamespace(string) ([]*gatewayv1beta1.ReferenceGrant, error) +} + +var _ referenceGrantFetcher = (*defaultReferenceGrantFetcher)(nil) + +type defaultReferenceGrantFetcher struct { + factory common.Factory + additionalResourcesByNamespace map[string][]*unstructured.Unstructured +} + +type referenceGrantFetcherOption func(*defaultReferenceGrantFetcher) + +func WithAdditionalResources(resources []*unstructured.Unstructured) referenceGrantFetcherOption { //nolint:revive + return func(f *defaultReferenceGrantFetcher) { + for _, resource := range resources { + if resource.GroupVersionKind().GroupKind() == common.ReferenceGrantGK { + f.additionalResourcesByNamespace[resource.GetNamespace()] = append(f.additionalResourcesByNamespace[resource.GetNamespace()], resource) + } + } + } +} + +func NewDefaultReferenceGrantFetcher(factory common.Factory, options ...referenceGrantFetcherOption) *defaultReferenceGrantFetcher { //nolint:revive + f := &defaultReferenceGrantFetcher{ + factory: factory, + additionalResourcesByNamespace: make(map[string][]*unstructured.Unstructured), + } + for _, option := range options { + option(f) + } + return f +} + +func (f *defaultReferenceGrantFetcher) FetchReferenceGrantsForNamespace(namespace string) ([]*gatewayv1beta1.ReferenceGrant, error) { + infos, err := f.factory.NewBuilder(). + Unstructured(). + Flatten(). + NamespaceParam(namespace).RequireNamespace(). + AllNamespaces(false). + ResourceTypeOrNameArgs(true, []string{fmt.Sprintf("%v.%v", common.ReferenceGrantGK.Kind, common.ReferenceGrantGK.Group)}...). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return nil, err + } + + var result []*gatewayv1beta1.ReferenceGrant + for _, info := range infos { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) + if err != nil { + return nil, err + } + refGrant := &gatewayv1beta1.ReferenceGrant{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u, refGrant); err != nil { + return nil, err + } + result = append(result, refGrant) + } + + // Return any additional ReferenceGrants if they have been provided. + for _, u := range f.additionalResourcesByNamespace[namespace] { + refGrant := &gatewayv1beta1.ReferenceGrant{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), refGrant); err != nil { + return nil, fmt.Errorf("converting local ReferenceGrant from Unstructurued to typed: %v", err) + } + result = append(result, refGrant) + } + + return result, nil +} diff --git a/pkg/cli/extension/utils/utils.go b/pkg/cli/extension/utils/utils.go new file mode 100644 index 00000000..40c21e5d --- /dev/null +++ b/pkg/cli/extension/utils/utils.go @@ -0,0 +1,42 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "github.com/flomesh-io/fsm/pkg/cli/extension/notfoundrefvalidator" + "github.com/flomesh-io/fsm/pkg/cli/extension/refgrantvalidator" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +func AggregateAnalysisErrors(node *topology.Node) ([]error, error) { + var analysisErrors []error + refGrantValidationMetadata, err := refgrantvalidator.Access(node) + if err != nil { + return nil, err + } + if refGrantValidationMetadata != nil && len(refGrantValidationMetadata.Errors) != 0 { + analysisErrors = append(analysisErrors, refGrantValidationMetadata.Errors...) + } + notFoundRefValidatorMetadata, err := notfoundrefvalidator.Access(node) + if err != nil { + return nil, err + } + if notFoundRefValidatorMetadata != nil && len(notFoundRefValidatorMetadata.Errors) != 0 { + analysisErrors = append(analysisErrors, notFoundRefValidatorMetadata.Errors...) + } + return analysisErrors, nil +} diff --git a/pkg/cli/flags/flags.go b/pkg/cli/flags/flags.go new file mode 100644 index 00000000..1355afbb --- /dev/null +++ b/pkg/cli/flags/flags.go @@ -0,0 +1,77 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package flags + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/pflag" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/flomesh-io/fsm/pkg/cli/common" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type ForFlag string + +func NewForFlag() *ForFlag { + f := ForFlag("") + return &f +} + +func (f *ForFlag) AddFlag(flagSet *pflag.FlagSet) { + flagSet.StringVar((*string)(f), "for", "", `Filter results to only those related to the specified resource. Format: TYPE[/NAMESPACE]/NAME. Not specifying a NAMESPACE assumes the 'default' value. Examples: gateway/ns2/foo-gateway, httproute/bar-httproute, service/ns1/my-svc`) +} + +func (f *ForFlag) ToOption() (common.GKNN, error) { + objRef := common.GKNN{} + + if *f != "" { + parts := strings.Split(string(*f), "/") + if len(parts) < 2 || len(parts) > 3 { + fmt.Fprintf(os.Stderr, "invalid value used in --for flag; value must be in the format TYPE[/NAMESPACE]/NAME\n") + os.Exit(1) + } + if len(parts) == 2 { + objRef = common.GKNN{Kind: parts[0], Namespace: metav1.NamespaceDefault, Name: parts[1]} + } else { + objRef = common.GKNN{Kind: parts[0], Namespace: parts[1], Name: parts[2]} + } + switch strings.ToLower(objRef.Kind) { + case "gatewayclass", "gateawyclasses": + objRef.Group = gatewayv1.GroupVersion.Group + objRef.Kind = "GatewayClass" + objRef.Namespace = "" + case "gateway", "gateways": + objRef.Group = gatewayv1.GroupVersion.Group + objRef.Kind = "Gateway" + case "httproute", "httproutes": + objRef.Group = gatewayv1.GroupVersion.Group + objRef.Kind = "HTTPRoute" + case "service", "services": + objRef.Kind = "Service" + default: + fmt.Fprintf(os.Stderr, "invalid type provided in --for flag; type must be one of [gatewayclass, gateway, httproute, service]\n") + os.Exit(1) + } + } + + return objRef, nil +} diff --git a/pkg/cli/policymanager/helpers.go b/pkg/cli/policymanager/helpers.go new file mode 100644 index 00000000..5ff3d41a --- /dev/null +++ b/pkg/cli/policymanager/helpers.go @@ -0,0 +1,30 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policymanager + +import "github.com/flomesh-io/fsm/pkg/cli/common" + +// ToPolicyRefs returns the Object references of all given policies. Note that +// these are not the value of targetRef within the Policies but rather the +// reference to the Policy object itself. +func ToPolicyRefs(policies []Policy) []common.GKNN { + var result []common.GKNN + for _, policy := range policies { + result = append(result, policy.GKNN()) + } + return result +} diff --git a/pkg/cli/policymanager/manager.go b/pkg/cli/policymanager/manager.go new file mode 100644 index 00000000..6c5d4abe --- /dev/null +++ b/pkg/cli/policymanager/manager.go @@ -0,0 +1,324 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policymanager + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + + "golang.org/x/exp/maps" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/flomesh-io/fsm/pkg/cli/common" + + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +type PolicyManager struct { + Fetcher common.GroupKindFetcher + + // policyCRDs maps a CRD name to the CRD object. + policyCRDs map[PolicyCrdID]*PolicyCRD + // policies maps a policy name to the policy object. + policies map[common.GKNN]*Policy +} + +func New(fetcher common.GroupKindFetcher) *PolicyManager { + return &PolicyManager{ + Fetcher: fetcher, + policyCRDs: make(map[PolicyCrdID]*PolicyCRD), + policies: make(map[common.GKNN]*Policy), + } +} + +// Init will construct a local cache of all Policy CRDs and Policy Resources. +func (p *PolicyManager) Init() error { + err := p.initPolicyCRDs() + if err != nil { + return err + } + + return p.initPolicies() +} + +func (p *PolicyManager) initPolicyCRDs() error { + crdGK := schema.GroupKind{Group: apiextensionsv1.GroupName, Kind: "CustomResourceDefinition"} + + allUnstructuredCRDs, err := p.Fetcher.Fetch(crdGK) + if err != nil { + return err + } + for _, uCRD := range allUnstructuredCRDs { + crd := &apiextensionsv1.CustomResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(uCRD.UnstructuredContent(), crd); err != nil { + panic(fmt.Sprintf("failed to convert unstructured CustomResourceDefinition to structured: %v", err)) + } + policyCRD := &PolicyCRD{crd} + // Check if the CRD is a Gateway Policy CRD + if policyCRD.IsValid() { + p.policyCRDs[policyCRD.ID()] = policyCRD + } + } + + return nil +} + +func (p *PolicyManager) initPolicies() error { + for _, policyCRD := range p.policyCRDs { + gk := schema.GroupKind{Group: policyCRD.CRD.Spec.Group, Kind: policyCRD.CRD.Spec.Names.Kind} + policies, err := p.Fetcher.Fetch(gk) + if err != nil { + return err + } + + for _, unstrucutredPolicy := range policies { + policy, err := ConstructPolicy(unstrucutredPolicy, policyCRD.IsInheritable()) + if err != nil { + return err + } + p.policies[policy.GKNN()] = &policy + } + } + return nil +} + +func (p *PolicyManager) PoliciesAttachedTo(objRef common.GKNN) []*Policy { + var result []*Policy + for _, policy := range p.policies { + if policy.IsAttachedTo(objRef) { + result = append(result, policy) + } + } + return result +} + +func (p *PolicyManager) GetCRDs() []*PolicyCRD { + return maps.Values(p.policyCRDs) +} + +func (p *PolicyManager) GetCRD(name string) (*PolicyCRD, bool) { + for _, policyCrd := range p.policyCRDs { + if name == policyCrd.CRD.Name { + return policyCrd, true + } + } + + return nil, false +} + +func (p *PolicyManager) GetPolicies() []*Policy { + return maps.Values(p.policies) +} + +// PolicyCrdID has the structurued "." +type PolicyCrdID string + +type PolicyCRD struct { + CRD *apiextensionsv1.CustomResourceDefinition +} + +// ID returns a unique identifier for this PolicyCRD. +func (p PolicyCRD) ID() PolicyCrdID { + return PolicyCrdID(p.CRD.Spec.Names.Kind + "." + p.CRD.Spec.Group) +} + +// IsValid return true if the PolicyCRD satisfies requirements for qualifying as +// a Gateway Policy CRD. +func (p PolicyCRD) IsValid() bool { + return p.IsInheritable() || p.IsDirect() || p.CRD.GetLabels()[gatewayv1alpha2.PolicyLabelKey] == "true" +} + +func (p PolicyCRD) IsInheritable() bool { + return strings.ToLower(p.CRD.GetLabels()[gatewayv1alpha2.PolicyLabelKey]) == "inherited" +} + +func (p PolicyCRD) IsDirect() bool { + return strings.ToLower(p.CRD.GetLabels()[gatewayv1alpha2.PolicyLabelKey]) == "direct" +} + +// IsClusterScoped returns true if the CRD is cluster scoped. Such policies can +// be used to target a cluster scoped resource like GatewayClass. +func (p PolicyCRD) IsClusterScoped() bool { + return p.CRD.Spec.Scope == apiextensionsv1.ClusterScoped +} + +type Policy struct { + Unstructured *unstructured.Unstructured + // TargetRefs references the target objects this policy is attached to. This + // only makes sense in case of a directly-attached-policy, or an + // unmerged-inherited-policy. + TargetRef common.GKNN + // Indicates whether the policy is supposed to be "inherited" (as opposed to + // "direct"). + Inheritable bool +} + +func ConstructPolicy(u *unstructured.Unstructured, inherited bool) (Policy, error) { + result := Policy{Unstructured: u} + + // Identify targetRef of Policy. + type genericPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec struct { + TargetRef gatewayv1alpha2.NamespacedPolicyTargetReference + } + } + structuredPolicy := &genericPolicy{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), structuredPolicy); err != nil { + return Policy{}, fmt.Errorf("failed to convert unstructured policy resource to structured: %v", err) + } + result.TargetRef = common.GKNN{ + Group: string(structuredPolicy.Spec.TargetRef.Group), + Kind: string(structuredPolicy.Spec.TargetRef.Kind), + Namespace: structuredPolicy.GetNamespace(), + Name: string(structuredPolicy.Spec.TargetRef.Name), + } + if result.TargetRef.Namespace == "" { + result.TargetRef.Namespace = result.Unstructured.GetNamespace() + } + if structuredPolicy.Spec.TargetRef.Namespace != nil { + result.TargetRef.Namespace = string(*structuredPolicy.Spec.TargetRef.Namespace) + } + + result.Inheritable = inherited + + return result, nil +} + +func (p Policy) GKNN() common.GKNN { + return common.GKNN{ + Group: p.Unstructured.GroupVersionKind().Group, + Kind: p.Unstructured.GroupVersionKind().Kind, + Namespace: p.Unstructured.GetNamespace(), + Name: p.Unstructured.GetName(), + } +} + +// PolicyCrdID returns a unique identifier for the CRD of this policy. +func (p Policy) PolicyCrdID() PolicyCrdID { + return PolicyCrdID(p.Unstructured.GetObjectKind().GroupVersionKind().Kind + "." + p.Unstructured.GetObjectKind().GroupVersionKind().Group) +} + +func (p Policy) IsInheritable() bool { + return p.Inheritable +} + +func (p Policy) IsDirect() bool { + return !p.Inheritable +} + +func (p Policy) IsAttachedTo(objRef common.GKNN) bool { + if p.TargetRef.Kind == K8sNamespaceKind && p.TargetRef.Name == "" { + p.TargetRef.Name = "default" + } + if objRef.Kind == K8sNamespaceKind && objRef.Name == "" { + objRef.Name = "default" + } + if p.TargetRef.Kind != K8sNamespaceKind && p.TargetRef.Namespace == "" { + p.TargetRef.Namespace = corev1.NamespaceDefault + } + if objRef.Kind != K8sNamespaceKind && objRef.Namespace == "" { + objRef.Namespace = corev1.NamespaceDefault + } + return p.TargetRef == objRef +} + +func (p Policy) DeepCopy() *Policy { + clone := &Policy{ + Unstructured: p.Unstructured.DeepCopy(), + TargetRef: p.TargetRef, + Inheritable: p.Inheritable, + } + return clone +} + +func (p Policy) Spec() map[string]interface{} { + spec, ok, err := unstructured.NestedFieldCopy(p.Unstructured.UnstructuredContent(), "spec") + if err != nil || !ok { + return nil + } + + result, ok := spec.(map[string]interface{}) + if !ok { + return nil + } + return result +} + +func (p Policy) EffectiveSpec() (map[string]interface{}, error) { + if !p.IsInheritable() { + // No merging is required in case of Direct policies. + result := p.Spec() + delete(result, "targetRef") + return result, nil + } + + spec := p.Spec() + if spec == nil { + return nil, nil + } + + defaultSpec, ok := p.Spec()["default"] + if !ok { + defaultSpec = make(map[string]interface{}) + } + overrideSpec, ok := p.Spec()["override"] + if !ok { + overrideSpec = make(map[string]interface{}) + } + + // Check if both are non-scalar and merge them. + defaultSpecNonScalar, isDefaultSpecNonScalar := defaultSpec.(map[string]interface{}) + overrideSpecNonScalar, isOverrideSpecNonScalar := overrideSpec.(map[string]interface{}) + if !isDefaultSpecNonScalar || !isOverrideSpecNonScalar { + return nil, fmt.Errorf("spec.default and spec.override must be non-scalar") + } + + result, err := mergeUnstructured(defaultSpecNonScalar, overrideSpecNonScalar) + if err != nil { + return nil, err + } + + return result, nil +} + +func (p *Policy) MarshalJSON() ([]byte, error) { + effectiveSpec, err := p.EffectiveSpec() + if err != nil { + return nil, err + } + return json.Marshal(effectiveSpec) +} + +func ConvertPoliciesMapToSlice(policies map[common.GKNN]*Policy) []*Policy { + result := maps.Values(policies) + sort.Slice(result, func(i, j int) bool { + a := fmt.Sprintf("%v/%v/%v", result[i].PolicyCrdID(), result[i].Unstructured.GetNamespace(), result[i].Unstructured.GetName()) + b := fmt.Sprintf("%v/%v/%v", result[j].PolicyCrdID(), result[j].Unstructured.GetNamespace(), result[j].Unstructured.GetName()) + return a < b + }) + return result +} diff --git a/pkg/cli/policymanager/merger.go b/pkg/cli/policymanager/merger.go new file mode 100644 index 00000000..fcd0304b --- /dev/null +++ b/pkg/cli/policymanager/merger.go @@ -0,0 +1,204 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policymanager + +import ( + "encoding/json" + "fmt" + + jsonpatch "github.com/evanphx/json-patch" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/flomesh-io/fsm/pkg/cli/common" +) + +// MergePoliciesOfSimilarKind will convert a slice a policies to a map of +// policies by merging policies of similar kind. The returned map will have the +// policy kind as the key. +func MergePoliciesOfSimilarKind(policies []*Policy) (map[PolicyCrdID]*Policy, error) { + result := make(map[PolicyCrdID]*Policy) + for _, policy := range policies { + policyCrdID := policy.PolicyCrdID() + + if _, ok := result[policyCrdID]; !ok { + // Policy of kind policyCrdID doesn't already exist so simply insert it + // into the resulting map. + result[policyCrdID] = policy + continue + } + + // At this point, we know that a policy of kind policyCrdID already exists + // so we need to merge the new policy with the existing one. + + // Merge existing policy with new policy. Reuse existing function to merge + // policies of similar hierarchy. + mergedPolicies, err := MergePoliciesOfSameHierarchy( + map[PolicyCrdID]*Policy{ + policyCrdID: result[policyCrdID], // Existing policy. + }, + map[PolicyCrdID]*Policy{ + policyCrdID: policy, // New policy. + }, + ) + if err != nil { + return nil, err + } + + result[policyCrdID] = mergedPolicies[policyCrdID] + } + return result, nil +} + +func MergePoliciesOfSameHierarchy(policies1, policies2 map[PolicyCrdID]*Policy) (map[PolicyCrdID]*Policy, error) { + return mergePolicies(policies1, policies2, orderPolicyByPrecedence) +} + +func MergePoliciesOfDifferentHierarchy(parentPolicies, childPolicies map[PolicyCrdID]*Policy) (map[PolicyCrdID]*Policy, error) { + return mergePolicies(parentPolicies, childPolicies, func(a, b *Policy) (*Policy, *Policy) { return a, b }) +} + +// mergePolicies will merge policies which are partitioned by their Kind. +// +// precedence function will order two policies such that the second policy +// returned will have a higher precedence. +func mergePolicies(policies1, policies2 map[PolicyCrdID]*Policy, precedence func(a, b *Policy) (*Policy, *Policy)) (map[PolicyCrdID]*Policy, error) { + result := make(map[PolicyCrdID]*Policy) + + // Copy policies1 into result. + for policyCrdID, policy := range policies1 { + result[policyCrdID] = policy + } + + // Merge policies2 with result. + for policyCrdID, policy := range policies2 { + existingPolicy, ok := result[policyCrdID] + if !ok { + // Policy of kind policyCrdID doesn't already exist so simply insert it + // into the resulting map. + result[policyCrdID] = policy + continue + } + + // Policy of kind policyCrdID already exists so merge them. + + lowerPolicy, higherPolicy := precedence(existingPolicy, policy) + + res, err := mergePolicy(lowerPolicy, higherPolicy) + if err != nil { + return nil, err + } + result[policyCrdID] = res + } + return result, nil +} + +// mergePolicy will merge two policies of similar kind. +// - overrides from parent will take precedence over the overrides from the +// child. +// - defaults from child will take precedence over the defaults from the +// parent. +func mergePolicy(parent, child *Policy) (*Policy, error) { + // Only policies of similar kind can be merged. + if parent.PolicyCrdID() != child.PolicyCrdID() { + return nil, fmt.Errorf("cannot merge policies of different kind; kind1=%v, kind2=%v", parent.PolicyCrdID(), child.PolicyCrdID()) + } + + resultUnstructured, err := mergeUnstructured(parent.Unstructured.UnstructuredContent(), child.Unstructured.UnstructuredContent()) + if err != nil { + return nil, err + } + + if parent.IsInheritable() { + // In case of an Inherited policy, the "spec.override" field of the parent + // should take precedence over the child. So we patch the override field + // from the parent into the result. + override, ok, err := unstructured.NestedFieldCopy(parent.Unstructured.UnstructuredContent(), "spec", "override") + if err != nil { + return nil, err + } + // If ok=false, it means "spec.override" field was missing, so we have + // nothing to do in that case. On the other hand, ok=true means + // "spec.override" field exists so we override the value of the parent. + if ok { + resultUnstructured, err = mergeUnstructured(resultUnstructured, map[string]interface{}{ + "spec": map[string]interface{}{ + "override": override, + }, + }) + if err != nil { + return nil, err + } + } + } + + result := child.DeepCopy() + result.Unstructured.SetUnstructuredContent(resultUnstructured) + // Merging two policies means the targetRef no longer makes any sense since + // since they can be conflicting. So we unset the targetRef. + result.TargetRef = common.GKNN{} + return result, nil +} + +func mergeUnstructured(parent, patch map[string]interface{}) (map[string]interface{}, error) { + currentJSON, err := json.Marshal(parent) + if err != nil { + return nil, err + } + + modifiedJSON, err := json.Marshal(patch) + if err != nil { + return nil, err + } + + resultJSON, err := jsonpatch.MergePatch(currentJSON, modifiedJSON) + if err != nil { + return nil, err + } + + result := make(map[string]interface{}) + if err := json.Unmarshal(resultJSON, &result); err != nil { + return nil, err + } + + return result, nil +} + +// orderPolicyByPrecedence will decide the precedence of two policies as per the +// [Gateway Specification]. The second policy returned will have a higher +// precedence. +// +// [Gateway Specification]: https://gateway-api.sigs.k8s.io/geps/gep-713/#conflict-resolution +func orderPolicyByPrecedence(a, b *Policy) (*Policy, *Policy) { + lowerPolicy := a.DeepCopy() // lowerPolicy will have lower precedence. + higherPolicy := b.DeepCopy() // higherPolicy will have higher precedence. + + if lowerPolicy.Unstructured.GetCreationTimestamp() == higherPolicy.Unstructured.GetCreationTimestamp() { + // Policies have the same creation time, so precedence is decided based + // on alphabetical ordering. + higherNN := fmt.Sprintf("%v/%v", higherPolicy.Unstructured.GetNamespace(), higherPolicy.Unstructured.GetName()) + lowerNN := fmt.Sprintf("%v/%v", lowerPolicy.Unstructured.GetNamespace(), lowerPolicy.Unstructured.GetName()) + if higherNN > lowerNN { + higherPolicy, lowerPolicy = lowerPolicy, higherPolicy + } + } else if higherPolicy.Unstructured.GetCreationTimestamp().Time.After(lowerPolicy.Unstructured.GetCreationTimestamp().Time) { + // Policies have difference creation time, so this will decide the precedence + higherPolicy, lowerPolicy = lowerPolicy, higherPolicy + } + + // At this point, higherPolicy will have precedence over lowerPolicy. + return lowerPolicy, higherPolicy +} diff --git a/pkg/cli/policymanager/merger_test.go b/pkg/cli/policymanager/merger_test.go new file mode 100644 index 00000000..11607f0b --- /dev/null +++ b/pkg/cli/policymanager/merger_test.go @@ -0,0 +1,447 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policymanager + +import ( + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestMergePoliciesOfSimilarKind(t *testing.T) { + timeSmall := metav1.Time{Time: time.Now().Add(-1 * time.Hour)}.String() + timeLarge := metav1.Time{Time: time.Now()}.String() + policies := []*Policy{ + { + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "foo.com/v1", + "kind": "HealthCheckPolicy", + "metadata": map[string]interface{}{ + "name": "health-check-1", + "creationTimestamp": timeSmall, + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "a", + "key3": "b", + }, + "default": map[string]interface{}{ + "key2": "d", + "key4": "e", + "key5": "c", + }, + }, + }, + }, + Inheritable: true, + }, + { + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "foo.com/v1", + "kind": "HealthCheckPolicy", + "metadata": map[string]interface{}{ + "name": "health-check-2", + "creationTimestamp": timeLarge, + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "f", + }, + "default": map[string]interface{}{ + "key2": "i", + "key4": "j", + }, + }, + }, + }, + Inheritable: true, + }, + { + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-1", + }, + "spec": map[string]interface{}{ + "condition": "path=/def", + "seconds": float64(30), + "targetRef": map[string]interface{}{ + "kind": "Namespace", + "name": "default", + }, + }, + }, + }, + }, + { + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "condition": "path=/abc", + "seconds": float64(60), + "targetRef": map[string]interface{}{ + "kind": "Namespace", + "name": "default", + }, + }, + }, + }, + }, + } + + want := map[PolicyCrdID]*Policy{ + PolicyCrdID("HealthCheckPolicy.foo.com"): { + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "foo.com/v1", + "kind": "HealthCheckPolicy", + "metadata": map[string]interface{}{ + "name": "health-check-1", + "creationTimestamp": timeSmall, + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "f", + "key3": "b", + }, + "default": map[string]interface{}{ + "key2": "d", + "key4": "e", + "key5": "c", + }, + }, + }, + }, + Inheritable: true, + }, + PolicyCrdID("TimeoutPolicy.bar.com"): { + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-1", + }, + "spec": map[string]interface{}{ + "condition": "path=/def", + "seconds": float64(30), + "targetRef": map[string]interface{}{ + "kind": "Namespace", + "name": "default", + }, + }, + }, + }, + }, + } + + got, err := MergePoliciesOfSimilarKind(policies) + if err != nil { + t.Fatalf("MergePoliciesOfSimilarKind returned err=%v; want no error", err) + } + cmpopts := cmp.Exporter(func(t reflect.Type) bool { + return t == reflect.TypeOf(Policy{}) + }) + if diff := cmp.Diff(want, got, cmpopts); diff != "" { + t.Errorf("MergePoliciesOfSimilarKind returned unexpected diff (-want, +got):\n%v", diff) + } +} + +func TestMergePoliciesOfDifferentHierarchy(t *testing.T) { + testCases := []struct { + name string + parentPolicies []*Policy + childPolicies []*Policy + + wantMergedPolicies []*Policy + wantErr bool + }{ + { + name: "parent and child both have overrides and defaults", + parentPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-1", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "parentValue1", + "key2": "parentValue2", + }, + "default": map[string]interface{}{ + "key4": "parentValue4", + "key5": "parentValue5", + }, + }, + }, + }, + }}, + childPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "childValue1", + "key3": "childValue3", + }, + "default": map[string]interface{}{ + "key4": "childValue4", + "key6": "childValue6", + }, + }, + }, + }, + }}, + wantMergedPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "parentValue1", + "key2": "parentValue2", + "key3": "childValue3", + }, + "default": map[string]interface{}{ + "key4": "childValue4", + "key5": "parentValue5", + "key6": "childValue6", + }, + }, + }, + }, + }}, + }, + { + name: "parent has defaults, child has overrides", + parentPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-1", + }, + "spec": map[string]interface{}{ + "default": map[string]interface{}{ + "key4": "parentValue4", + "key5": "parentValue5", + }, + }, + }, + }, + }}, + childPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "childValue1", + "key3": "childValue3", + }, + }, + }, + }, + }}, + wantMergedPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "childValue1", + "key3": "childValue3", + }, + "default": map[string]interface{}{ + "key4": "parentValue4", + "key5": "parentValue5", + }, + }, + }, + }, + }}, + }, + { + name: "policies of different kind do not intersect with each other", + parentPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "foo.com/v1", + "kind": "HealthCheckPolicy", + "metadata": map[string]interface{}{ + "name": "health-check-1", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "a", + "key3": "b", + }, + "default": map[string]interface{}{ + "key2": "d", + "key4": "e", + "key5": "c", + }, + }, + }, + }, + }}, + childPolicies: []*Policy{{ + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "childValue1", + "key3": "childValue3", + }, + "default": map[string]interface{}{ + "key4": "childValue4", + "key6": "childValue6", + }, + }, + }, + }, + }}, + wantMergedPolicies: []*Policy{ + { + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "timeout-policy-2", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "childValue1", + "key3": "childValue3", + }, + "default": map[string]interface{}{ + "key4": "childValue4", + "key6": "childValue6", + }, + }, + }, + }, + }, + { + Inheritable: true, + Unstructured: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "foo.com/v1", + "kind": "HealthCheckPolicy", + "metadata": map[string]interface{}{ + "name": "health-check-1", + }, + "spec": map[string]interface{}{ + "override": map[string]interface{}{ + "key1": "a", + "key3": "b", + }, + "default": map[string]interface{}{ + "key2": "d", + "key4": "e", + "key5": "c", + }, + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotMergedPolicies, err := MergePoliciesOfDifferentHierarchy( + policySliceToMap(tc.parentPolicies), + policySliceToMap(tc.childPolicies), + ) + + if (err != nil) != tc.wantErr { + t.Fatalf("MergePoliciesOfDifferentHierarchy(...) returned err=%v; want err=%v", err, tc.wantErr) + } + + // Use a custom transformer to only compare specific fields of the Policy + // that we are interested in testing. + cmpopts := cmp.Transformer("PolicyTransformer", func(p Policy) map[string]interface{} { + return map[string]interface{}{ + "Object": p.Unstructured, + "Inherited": p.Inheritable, + } + }) + if diff := cmp.Diff(policySliceToMap(tc.wantMergedPolicies), gotMergedPolicies, cmpopts); diff != "" { + t.Errorf("MergePoliciesOfDifferentHierarchy returned unexpected diff (-want, +got):\n%v", diff) + } + }) + } +} + +func policySliceToMap(policies []*Policy) map[PolicyCrdID]*Policy { + res := make(map[PolicyCrdID]*Policy) + for _, policy := range policies { + res[policy.PolicyCrdID()] = policy + } + return res +} diff --git a/pkg/cli/policymanager/types.go b/pkg/cli/policymanager/types.go new file mode 100644 index 00000000..8b6a231d --- /dev/null +++ b/pkg/cli/policymanager/types.go @@ -0,0 +1,5 @@ +package policymanager + +const ( + K8sNamespaceKind string = "Namespace" +) diff --git a/pkg/cli/printer/backends.go b/pkg/cli/printer/backends.go new file mode 100644 index 00000000..fae6735a --- /dev/null +++ b/pkg/cli/printer/backends.go @@ -0,0 +1,193 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + "strings" + + "golang.org/x/exp/maps" + "k8s.io/apimachinery/pkg/util/duration" + + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/gatewayeffectivepolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/refgrantvalidator" + extensionutils "github.com/flomesh-io/fsm/pkg/cli/extension/utils" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +func (p *TablePrinter) printBackend(backendNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("Backend", w); err != nil { + return err + } + + if p.table == nil { + var columnNames []string + if p.OutputFormat == OutputFormatWide { + columnNames = []string{"NAMESPACE", "NAME", "TYPE", "AGE", "REFERRED BY ROUTES", "POLICIES"} + } else { + columnNames = []string{"NAMESPACE", "NAME", "TYPE", "AGE"} + } + p.table = &Table{ + ColumnNames: columnNames, + UseSeparator: false, + } + } + + backend := backendNode.Object + + namespace := backend.GetNamespace() + name := backend.GetName() + backendType := backend.GetKind() + + age := UnknownAge + creationTimestamp := backend.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + namespace, + name, + backendType, + age, + } + if p.OutputFormat == OutputFormatWide { + httpRouteNodes := maps.Values(topologygw.BackendNode(backendNode).HTTPRoutes()) + sortedHTTPRouteNodes := topology.SortedNodes(httpRouteNodes) + totalRoutes := len(sortedHTTPRouteNodes) + var referredByRoutes string + if totalRoutes == 0 { + referredByRoutes = "None" + } else { + var routes []string + for i, httpRouteNode := range sortedHTTPRouteNodes { + if i < 2 { + namespacedName := httpRouteNode.GKNN().NamespacedName().String() + routes = append(routes, namespacedName) + } else { + break + } + } + referredByRoutes = strings.Join(routes, ", ") + if totalRoutes > 2 { + referredByRoutes += fmt.Sprintf(" + %d more", totalRoutes-2) + } + } + policiesMap, err := directlyattachedpolicy.Access(backendNode) + if err != nil { + return err + } + policiesCount := fmt.Sprintf("%d", len(policiesMap)) + row = append(row, referredByRoutes, policiesCount) + } + p.table.Rows = append(p.table.Rows, row) + + return nil +} + +func (p *DescriptionPrinter) printBackend(backendNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + backend := backendNode.Object.DeepCopy() + backend.SetLabels(nil) + backend.SetAnnotations(nil) + + pairs := []*DescriberKV{ + {Key: "Name", Value: backendNode.Object.GetName()}, + {Key: "Namespace", Value: backendNode.Object.GetNamespace()}, + {Key: "Labels", Value: backendNode.Object.GetLabels()}, + {Key: "Annotations", Value: backendNode.Object.GetAnnotations()}, + {Key: "Backend", Value: backend}, + } + + // ReferencedByRoutes + routes := &Table{ + ColumnNames: []string{"Kind", "Name"}, + UseSeparator: true, + } + for _, httpRouteNode := range topologygw.BackendNode(backendNode).HTTPRoutes() { + row := []string{ + httpRouteNode.GKNN().Kind, // Kind + httpRouteNode.GKNN().NamespacedName().String(), // Name + } + routes.Rows = append(routes.Rows, row) + } + pairs = append(pairs, &DescriberKV{Key: "ReferencedByRoutes", Value: routes}) + + // DirectlyAttachedPolicies + policiesMap, err := directlyattachedpolicy.Access(backendNode) + if err != nil { + return err + } + policies := policymanager.ConvertPoliciesMapToSlice(policiesMap) + pairs = append(pairs, &DescriberKV{Key: "DirectlyAttachedPolicies", Value: convertPoliciesToRefsTable(policies, false)}) + + // InheritedPolicies + effectivePolicies, err := gatewayeffectivepolicy.Access(backendNode) + if err != nil { + return err + } + policies = policymanager.ConvertPoliciesMapToSlice(effectivePolicies.BackendInheritedPolicies) + pairs = append(pairs, &DescriberKV{Key: "InheritedPolicies", Value: convertPoliciesToRefsTable(policies, true)}) + + // EffectivePolicies + if err != nil { + return err + } + if len(effectivePolicies.BackendEffectivePolicies) != 0 { + pairs = append(pairs, &DescriberKV{Key: "EffectivePolicies", Value: effectivePolicies.BackendEffectivePolicies}) + } + + // ReferenceGrants + referenceGrantsMetadata, err := refgrantvalidator.Access(backendNode) + if err != nil { + return err + } + if referenceGrantsMetadata != nil && len(referenceGrantsMetadata.ReferenceGrants) != 0 { + var names []string + for _, refGrantNode := range referenceGrantsMetadata.ReferenceGrants { + names = append(names, refGrantNode.GetName()) + } + pairs = append(pairs, &DescriberKV{Key: "ReferenceGrants", Value: names}) + } + + // Analysis + analysisErrors, err := extensionutils.AggregateAnalysisErrors(backendNode) + if err != nil { + return err + } + if len(analysisErrors) != 0 { + pairs = append(pairs, &DescriberKV{Key: "Analysis", Value: convertErrorsToString(analysisErrors)}) + } + + // Events + events, err := p.EventFetcher.FetchEventsFor(backendNode.Object) + if err != nil { + return err + } + pairs = append(pairs, &DescriberKV{Key: "Events", Value: convertEventsSliceToTable(events, p.Clock)}) + + Describe(w, pairs) + return nil +} diff --git a/pkg/cli/printer/backends_test.go b/pkg/cli/printer/backends_test.go new file mode 100644 index 00000000..3a2805c0 --- /dev/null +++ b/pkg/cli/printer/backends_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestTablePrinter_printBackend(t *testing.T) { + options := PrinterOptions{} + p := &TablePrinter{PrinterOptions: options} + out := &bytes.Buffer{} + + for _, ns := range testData(t)[common.ServiceGK] { + p.printBackend(ns, out) + p.Flush(out) + } + + wantOut := ` +NAMESPACE NAME TYPE AGE +ns-1 svc-1 Service +` + + got := common.MultiLine(out.String()) + want := common.MultiLine(strings.TrimPrefix(wantOut, "\n")) + + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } +} diff --git a/pkg/cli/printer/gatewayclasses.go b/pkg/cli/printer/gatewayclasses.go new file mode 100644 index 00000000..3cabc553 --- /dev/null +++ b/pkg/cli/printer/gatewayclasses.go @@ -0,0 +1,149 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + + "golang.org/x/exp/maps" + "k8s.io/apimachinery/pkg/util/duration" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +func (p *TablePrinter) printGatewayClass(gatewayClassNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("GatewayClass", w); err != nil { + return err + } + + if p.table == nil { + var columnNames []string + if p.OutputFormat == OutputFormatWide { + columnNames = []string{"NAME", "CONTROLLER", "ACCEPTED", "AGE", "GATEWAYS"} + } else { + columnNames = []string{"NAME", "CONTROLLER", "ACCEPTED", "AGE"} + } + p.table = &Table{ + ColumnNames: columnNames, + UseSeparator: false, + } + } + + gatewayClass := topology.MustAccessObject(gatewayClassNode, &gatewayv1.GatewayClass{}) + + accepted := "Unknown" + for _, condition := range gatewayClass.Status.Conditions { + if condition.Type == "Accepted" { + accepted = string(condition.Status) + } + } + + age := UnknownAge + creationTimestamp := gatewayClass.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + gatewayClass.GetName(), + string(gatewayClass.Spec.ControllerName), + accepted, + age, + } + if p.OutputFormat == OutputFormatWide { + gatewayCount := fmt.Sprintf("%d", len(topologygw.GatewayClassNode(gatewayClassNode).Gateways())) + row = append(row, gatewayCount) + } + p.table.Rows = append(p.table.Rows, row) + return nil +} + +func (p *DescriptionPrinter) printGatewayClass(gatewayClassNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + gatewayClass := topology.MustAccessObject(gatewayClassNode, &gatewayv1.GatewayClass{}) + + metadata := gatewayClass.ObjectMeta.DeepCopy() + metadata.Labels = nil + metadata.Annotations = nil + metadata.Name = "" + metadata.Namespace = "" + metadata.ManagedFields = nil + + pairs := []*DescriberKV{ + {Key: "Name", Value: gatewayClass.GetName()}, + {Key: "Labels", Value: gatewayClass.GetLabels()}, + {Key: "Annotations", Value: gatewayClass.GetAnnotations()}, + {Key: "APIVersion", Value: gatewayClass.APIVersion}, + {Key: "Kind", Value: gatewayClass.Kind}, + {Key: "Metadata", Value: metadata}, + {Key: "Spec", Value: &gatewayClass.Spec}, + {Key: "Status", Value: &gatewayClass.Status}, + } + + const ( + maxGateways = 10 + ) + + // AttachedGateways + attachedGateways := &Table{ + ColumnNames: []string{"Kind", "Name"}, + UseSeparator: true, + } + gatewaysCount := 0 + gatewayNodes := maps.Values(topologygw.GatewayClassNode(gatewayClassNode).Gateways()) + for _, gatewayNode := range topology.SortedNodes(gatewayNodes) { + gatewaysCount++ + if gatewaysCount > maxGateways { + attachedGateways.Rows = append(attachedGateways.Rows, []string{"(Truncated)"}) + break + } + row := []string{ + gatewayNode.GKNN().Kind, // Kind + gatewayNode.GKNN().NamespacedName().String(), // Name + } + attachedGateways.Rows = append(attachedGateways.Rows, row) + } + pairs = append(pairs, &DescriberKV{Key: "AttachedGateways", Value: attachedGateways}) + + // DirectlyAttachedPolicies + policiesMap, err := directlyattachedpolicy.Access(gatewayClassNode) + if err != nil { + return err + } + policies := policymanager.ConvertPoliciesMapToSlice(policiesMap) + pairs = append(pairs, &DescriberKV{Key: "DirectlyAttachedPolicies", Value: convertPoliciesToRefsTable(policies, false)}) + + // Events + events, err := p.EventFetcher.FetchEventsFor(gatewayClass) + if err != nil { + return err + } + pairs = append(pairs, &DescriberKV{Key: "Events", Value: convertEventsSliceToTable(events, p.Clock)}) + + Describe(w, pairs) + return nil +} diff --git a/pkg/cli/printer/gatewayclasses_test.go b/pkg/cli/printer/gatewayclasses_test.go new file mode 100644 index 00000000..0f1f8de3 --- /dev/null +++ b/pkg/cli/printer/gatewayclasses_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestTablePrinter_printGatewayClass(t *testing.T) { + options := PrinterOptions{} + p := &TablePrinter{PrinterOptions: options} + out := &bytes.Buffer{} + + for _, ns := range testData(t)[common.GatewayClassGK] { + p.printGatewayClass(ns, out) + p.Flush(out) + } + + wantOut := ` +NAME CONTROLLER ACCEPTED AGE +gateway-class-1 foo.com/external-gateway-class True +` + + got := common.MultiLine(out.String()) + want := common.MultiLine(strings.TrimPrefix(wantOut, "\n")) + + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } +} diff --git a/pkg/cli/printer/gateways.go b/pkg/cli/printer/gateways.go new file mode 100644 index 00000000..98fff358 --- /dev/null +++ b/pkg/cli/printer/gateways.go @@ -0,0 +1,223 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + "strings" + + "golang.org/x/exp/maps" + "k8s.io/apimachinery/pkg/util/duration" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/gatewayeffectivepolicy" + extensionutils "github.com/flomesh-io/fsm/pkg/cli/extension/utils" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" + topologygw "github.com/flomesh-io/fsm/pkg/cli/topology/gateway" +) + +func (p *TablePrinter) printGateway(gatewayNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("Gateway", w); err != nil { + return err + } + + if p.table == nil { + var columnNames []string + if p.OutputFormat == OutputFormatWide { + columnNames = []string{"NAMESPACE", "NAME", "CLASS", "ADDRESSES", "PORTS", "PROGRAMMED", "AGE", "POLICIES", "HTTPROUTES"} + } else { + columnNames = []string{"NAMESPACE", "NAME", "CLASS", "ADDRESSES", "PORTS", "PROGRAMMED", "AGE"} + } + p.table = &Table{ + ColumnNames: columnNames, + UseSeparator: false, + } + } + + gateway := topology.MustAccessObject(gatewayNode, &gatewayv1.Gateway{}) + + var addresses []string + for _, address := range gateway.Status.Addresses { + addresses = append(addresses, address.Value) + } + addressesOutput := strings.Join(addresses, ",") + if cnt := len(addresses); cnt > 2 { + addressesOutput = fmt.Sprintf("%v + %v more", strings.Join(addresses[:2], ","), cnt-2) + } + + var ports []string + for _, listener := range gateway.Spec.Listeners { + ports = append(ports, fmt.Sprintf("%d", int(listener.Port))) + } + portsOutput := strings.Join(ports, ",") + + programmedStatus := "Unknown" + for _, condition := range gateway.Status.Conditions { + if condition.Type == "Programmed" { + programmedStatus = string(condition.Status) + break + } + } + + age := UnknownAge + creationTimestamp := gateway.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + gateway.GetNamespace(), + gateway.GetName(), + string(gateway.Spec.GatewayClassName), + addressesOutput, + portsOutput, + programmedStatus, + age, + } + if p.OutputFormat == OutputFormatWide { + policiesMap, err := directlyattachedpolicy.Access(gatewayNode) + if err != nil { + return err + } + policiesCount := fmt.Sprintf("%d", len(policiesMap)) + + httpRoutesCount := fmt.Sprintf("%d", len(topologygw.GatewayNode(gatewayNode).HTTPRoutes())) + + row = append(row, policiesCount, httpRoutesCount) + } + p.table.Rows = append(p.table.Rows, row) + + return nil +} + +func (p *DescriptionPrinter) printGateway(gatewayNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + gateway := topology.MustAccessObject(gatewayNode, &gatewayv1.Gateway{}) + + metadata := gateway.ObjectMeta.DeepCopy() + metadata.Labels = nil + metadata.Annotations = nil + metadata.Name = "" + metadata.Namespace = "" + metadata.ManagedFields = nil + + pairs := []*DescriberKV{ + {Key: "Name", Value: gateway.GetName()}, + {Key: "Namespace", Value: gateway.GetNamespace()}, + {Key: "Labels", Value: gateway.Labels}, + {Key: "Annotations", Value: gateway.Annotations}, + {Key: "APIVersion", Value: gateway.APIVersion}, + {Key: "Kind", Value: gateway.Kind}, + {Key: "Metadata", Value: metadata}, + {Key: "Spec", Value: &gateway.Spec}, + {Key: "Status", Value: &gateway.Status}, + } + + const ( + maxHTTPRoutes = 10 + maxBackends = 10 + ) + + // AttachedRoutes + attachedRoutes := &Table{ + ColumnNames: []string{"Kind", "Name"}, + UseSeparator: true, + } + // Backends + backends := &Table{ + ColumnNames: []string{"Kind", "Name"}, + UseSeparator: true, + } + httpRouteCount, backendsCount := 0, 0 + httpRouteNodes := maps.Values(topologygw.GatewayNode(gatewayNode).HTTPRoutes()) + for _, httpRouteNode := range topology.SortedNodes(httpRouteNodes) { + httpRouteCount++ + if httpRouteCount > maxHTTPRoutes { + attachedRoutes.Rows = append(attachedRoutes.Rows, []string{"(Truncated)"}) + break + } + row := []string{ + httpRouteNode.GKNN().Kind, // Kind + httpRouteNode.GKNN().NamespacedName().String(), // Name + } + attachedRoutes.Rows = append(attachedRoutes.Rows, row) + + backendNodes := maps.Values(topologygw.HTTPRouteNode(httpRouteNode).Backends()) + for _, backendNode := range topology.SortedNodes(backendNodes) { + backendsCount++ + if backendsCount > maxBackends { + backends.Rows = append(backends.Rows, []string{"(Truncated)"}) + break + } + row := []string{ + backendNode.GKNN().Kind, // Kind + backendNode.GKNN().NamespacedName().String(), // Name + } + backends.Rows = append(backends.Rows, row) + } + } + pairs = append(pairs, &DescriberKV{Key: "AttachedRoutes", Value: attachedRoutes}) + pairs = append(pairs, &DescriberKV{Key: "Backends", Value: backends}) + + // DirectlyAttachedPolicies + policiesMap, err := directlyattachedpolicy.Access(gatewayNode) + if err != nil { + return err + } + policies := policymanager.ConvertPoliciesMapToSlice(policiesMap) + pairs = append(pairs, &DescriberKV{Key: "DirectlyAttachedPolicies", Value: convertPoliciesToRefsTable(policies, false)}) + + // InheritedPolicies + effectivePolicies, err := gatewayeffectivepolicy.Access(gatewayNode) + if err != nil { + return err + } + policies = policymanager.ConvertPoliciesMapToSlice(effectivePolicies.GatewayInheritedPolicies) + pairs = append(pairs, &DescriberKV{Key: "InheritedPolicies", Value: convertPoliciesToRefsTable(policies, true)}) + + // EffectivePolicies`` + if len(effectivePolicies.GatewayEffectivePolicies) != 0 { + pairs = append(pairs, &DescriberKV{Key: "EffectivePolicies", Value: effectivePolicies.GatewayEffectivePolicies}) + } + + // // Analysis + analysisErrors, err := extensionutils.AggregateAnalysisErrors(gatewayNode) + if err != nil { + return err + } + if len(analysisErrors) != 0 { + pairs = append(pairs, &DescriberKV{Key: "Analysis", Value: convertErrorsToString(analysisErrors)}) + } + + // Events + events, err := p.EventFetcher.FetchEventsFor(gateway) + if err != nil { + return err + } + pairs = append(pairs, &DescriberKV{Key: "Events", Value: convertEventsSliceToTable(events, p.Clock)}) + + Describe(w, pairs) + return nil +} diff --git a/pkg/cli/printer/gateways_test.go b/pkg/cli/printer/gateways_test.go new file mode 100644 index 00000000..3bfbeb53 --- /dev/null +++ b/pkg/cli/printer/gateways_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestTablePrinter_printGateway(t *testing.T) { + options := PrinterOptions{} + p := &TablePrinter{PrinterOptions: options} + out := &bytes.Buffer{} + + for _, ns := range testData(t)[common.GatewayGK] { + p.printGateway(ns, out) + p.Flush(out) + } + + wantOut := ` +NAMESPACE NAME CLASS ADDRESSES PORTS PROGRAMMED AGE +ns-1 gateway-1 gateway-class-1 10.0.0.1,10.0.0.2 + 1 more 80 True +` + + got := common.MultiLine(out.String()) + want := common.MultiLine(strings.TrimPrefix(wantOut, "\n")) + + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } +} diff --git a/pkg/cli/printer/httproutes.go b/pkg/cli/printer/httproutes.go new file mode 100644 index 00000000..83f8cba3 --- /dev/null +++ b/pkg/cli/printer/httproutes.go @@ -0,0 +1,165 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + "strings" + + "k8s.io/apimachinery/pkg/util/duration" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/extension/gatewayeffectivepolicy" + extensionutils "github.com/flomesh-io/fsm/pkg/cli/extension/utils" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +func (p *TablePrinter) printHTTPRoute(httpRouteNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("HTTPRoute", w); err != nil { + return err + } + + if p.table == nil { + var columnNames []string + if p.OutputFormat == OutputFormatWide { + columnNames = []string{"NAMESPACE", "NAME", "HOSTNAMES", "PARENT REFS", "AGE", "POLICIES"} + } else { + columnNames = []string{"NAMESPACE", "NAME", "HOSTNAMES", "PARENT REFS", "AGE"} + } + + p.table = &Table{ + ColumnNames: columnNames, + UseSeparator: false, + } + } + + httpRoute := topology.MustAccessObject(httpRouteNode, &gatewayv1.HTTPRoute{}) + + var hostNames []string + for _, hostName := range httpRoute.Spec.Hostnames { + hostNames = append(hostNames, string(hostName)) + } + hostNamesOutput := "None" + if hostNamesCount := len(hostNames); hostNamesCount > 0 { + if hostNamesCount > 2 { + hostNamesOutput = fmt.Sprintf("%v + %v more", strings.Join(hostNames[:2], ","), hostNamesCount-2) + } else { + hostNamesOutput = strings.Join(hostNames, ",") + } + } + + parentRefsCount := fmt.Sprintf("%d", len(httpRoute.Spec.ParentRefs)) + + age := UnknownAge + creationTimestamp := httpRoute.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + httpRoute.GetNamespace(), + httpRoute.GetName(), + hostNamesOutput, + parentRefsCount, + age, + } + if p.OutputFormat == OutputFormatWide { + policiesMap, err := directlyattachedpolicy.Access(httpRouteNode) + if err != nil { + return err + } + policiesCount := fmt.Sprintf("%d", len(policiesMap)) + row = append(row, policiesCount) + } + p.table.Rows = append(p.table.Rows, row) + return nil +} + +func (p *DescriptionPrinter) printHTTPRoute(httpRouteNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + httpRoute := topology.MustAccessObject(httpRouteNode, &gatewayv1.HTTPRoute{}) + + metadata := httpRoute.ObjectMeta.DeepCopy() + metadata.Labels = nil + metadata.Annotations = nil + metadata.Name = "" + metadata.Namespace = "" + metadata.ManagedFields = nil + + pairs := []*DescriberKV{ + {"Name", httpRoute.GetName()}, + {"Namespace", httpRoute.Namespace}, + {"Label", httpRoute.Labels}, + {"Annotations", httpRoute.Annotations}, + {"APIVersion", httpRoute.APIVersion}, + {"Kind", httpRoute.Kind}, + {"Metadata", metadata}, + {"Spec", httpRoute.Spec}, + {"Status", httpRoute.Status}, + } + + // DirectlyAttachedPolicies + policiesMap, err := directlyattachedpolicy.Access(httpRouteNode) + if err != nil { + return err + } + policies := policymanager.ConvertPoliciesMapToSlice(policiesMap) + pairs = append(pairs, &DescriberKV{Key: "DirectlyAttachedPolicies", Value: convertPoliciesToRefsTable(policies, false)}) + + // InheritedPolicies + effectivePolicies, err := gatewayeffectivepolicy.Access(httpRouteNode) + if err != nil { + return err + } + policies = policymanager.ConvertPoliciesMapToSlice(effectivePolicies.HTTPRouteInheritedPolicies) + pairs = append(pairs, &DescriberKV{Key: "InheritedPolicies", Value: convertPoliciesToRefsTable(policies, true)}) + + // EffectivePolicies + if err != nil { + return err + } + if len(effectivePolicies.HTTPRouteEffectivePolicies) != 0 { + pairs = append(pairs, &DescriberKV{Key: "EffectivePolicies", Value: effectivePolicies.HTTPRouteEffectivePolicies}) + } + + // Analysis + analysisErrors, err := extensionutils.AggregateAnalysisErrors(httpRouteNode) + if err != nil { + return err + } + if len(analysisErrors) != 0 { + pairs = append(pairs, &DescriberKV{Key: "Analysis", Value: convertErrorsToString(analysisErrors)}) + } + + // Events + events, err := p.EventFetcher.FetchEventsFor(httpRoute) + if err != nil { + return err + } + pairs = append(pairs, &DescriberKV{Key: "Events", Value: convertEventsSliceToTable(events, p.Clock)}) + + Describe(w, pairs) + return nil +} diff --git a/pkg/cli/printer/httproutes_test.go b/pkg/cli/printer/httproutes_test.go new file mode 100644 index 00000000..eb525b43 --- /dev/null +++ b/pkg/cli/printer/httproutes_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestTablePrinter_printHTTPRoute(t *testing.T) { + options := PrinterOptions{} + p := &TablePrinter{PrinterOptions: options} + out := &bytes.Buffer{} + + for _, ns := range testData(t)[common.HTTPRouteGK] { + p.printHTTPRoute(ns, out) + p.Flush(out) + } + + wantOut := ` +NAMESPACE NAME HOSTNAMES PARENT REFS AGE +ns-1 http-route-1 foo.com,bar.com + 5 more 1 +` + + got := common.MultiLine(out.String()) + want := common.MultiLine(strings.TrimPrefix(wantOut, "\n")) + + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } +} diff --git a/pkg/cli/printer/main_test.go b/pkg/cli/printer/main_test.go new file mode 100644 index 00000000..ae33da62 --- /dev/null +++ b/pkg/cli/printer/main_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "flag" + "os" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + + "k8s.io/klog/v2" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestMain(m *testing.M) { + fs := flag.NewFlagSet("mock-flags", flag.PanicOnError) + klog.InitFlags(fs) + fs.Set("v", "3") // Set klog verbosity. + + os.Exit(m.Run()) +} + +func testData(t *testing.T) map[schema.GroupKind][]*topology.Node { + ns1 := mustNewNode(t, &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-1", + }, + Status: corev1.NamespaceStatus{ + Phase: corev1.NamespaceActive, + }, + }) + + gatewayClass1 := mustNewNode(t, &gatewayv1.GatewayClass{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayv1.GroupVersion.String(), + Kind: "GatewayClass", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-class-1", + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: "foo.com/external-gateway-class", + }, + Status: gatewayv1.GatewayClassStatus{ + Conditions: []metav1.Condition{ + { + Type: "Accepted", + Status: "True", + }, + }, + }, + }) + + gateway1 := mustNewNode(t, + &gatewayv1.Gateway{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayv1.GroupVersion.String(), + Kind: "Gateway", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-1", + Namespace: "ns-1", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "gateway-class-1", + Listeners: []gatewayv1.Listener{ + { + Name: gatewayv1.SectionName("http-80"), + Protocol: gatewayv1.HTTPProtocolType, + Port: gatewayv1.PortNumber(80), + }, + }, + }, + Status: gatewayv1.GatewayStatus{ + Addresses: []gatewayv1.GatewayStatusAddress{ + { + Value: "10.0.0.1", + }, + { + Value: "10.0.0.2", + }, + { + Value: "10.0.0.3", + }, + }, + Conditions: []metav1.Condition{ + { + Type: "Programmed", + Status: "True", + }, + }, + }, + }, + ) + + httpRoute1 := mustNewNode(t, + &gatewayv1.HTTPRoute{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayv1.GroupVersion.String(), + Kind: "HTTPRoute", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "http-route-1", + Namespace: "ns-1", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"foo.com", "bar.com", "example.com", "example2.com", "example3.com", "example4.com", "example5.com"}, + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Kind: ptr.To(gatewayv1.Kind("Gateway")), + Group: ptr.To(gatewayv1.Group("gateway.networking.k8s.io")), + Namespace: ptr.To(gatewayv1.Namespace("ns-1")), + Name: "gateway-1", + }, + }, + }, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Port: ptr.To(gatewayv1.PortNumber(8080)), + Name: gatewayv1.ObjectName("service-1"), + Kind: ptr.To(gatewayv1.Kind("Service")), + Namespace: ptr.To(gatewayv1.Namespace("ns-1")), + }, + }, + }, + }, + }, + }, + }, + }, + ) + + service1 := mustNewNode(t, &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "svc-1", + Namespace: "ns-1", + }, + }) + + graph := &topology.Graph{} + graph.AddNode(ns1) + graph.AddNode(gatewayClass1) + graph.AddNode(gateway1) + graph.AddNode(httpRoute1) + graph.AddNode(service1) + + result := map[schema.GroupKind][]*topology.Node{} + for gk, nodes := range graph.Nodes { + for _, node := range nodes { + result[gk] = append(result[gk], node) + } + } + return result +} + +func testPoliciesData(t *testing.T) map[schema.GroupKind][]*topology.Node { + return map[schema.GroupKind][]*topology.Node{ + common.PolicyGK: { + mustNewPolicyNode(t, &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "bar.com/v1", + "kind": "TimeoutPolicy", + "metadata": map[string]interface{}{ + "name": "policy-1", + "namespace": "ns-1", + }, + "spec": map[string]interface{}{ + "condition": "path=/abc", + "seconds": int64(30), + "targetRef": map[string]interface{}{ + "kind": "Namespace", + "name": "default", + }, + }, + }, + }, false), + }, + } +} + +func mustNewNode(t *testing.T, obj runtime.Object) *topology.Node { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + t.Fatal(err) + } + return &topology.Node{Object: &unstructured.Unstructured{Object: u}} +} + +func mustNewPolicyNode(t *testing.T, u *unstructured.Unstructured, inherited bool) *topology.Node { + policy, err := policymanager.ConstructPolicy(u, inherited) + if err != nil { + t.Fatal(err) + } + + return &topology.Node{ + Object: policy.Unstructured, + Metadata: map[string]any{ + common.PolicyGK.String(): policy, + }, + } +} diff --git a/pkg/cli/printer/namespace.go b/pkg/cli/printer/namespace.go new file mode 100644 index 00000000..3657f56b --- /dev/null +++ b/pkg/cli/printer/namespace.go @@ -0,0 +1,113 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/duration" + + "github.com/flomesh-io/fsm/pkg/cli/extension/directlyattachedpolicy" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +func (p *TablePrinter) printNamespace(namespaceNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("Namespace", w); err != nil { + return err + } + + if p.table == nil { + var columnNames []string + if p.OutputFormat == OutputFormatWide { + columnNames = []string{"NAME", "STATUS", "AGE", "POLICIES"} + } else { + columnNames = []string{"NAME", "STATUS", "AGE"} + } + p.table = &Table{ + ColumnNames: columnNames, + UseSeparator: false, + } + } + + ns := topology.MustAccessObject(namespaceNode, &corev1.Namespace{}) + + age := UnknownAge + creationTimestamp := ns.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + ns.Name, + string(ns.Status.Phase), + age, + } + if p.OutputFormat == OutputFormatWide { + policiesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + policiesCount := fmt.Sprintf("%d", len(policiesMap)) + row = append(row, policiesCount) + } + p.table.Rows = append(p.table.Rows, row) + return nil +} + +func (p *DescriptionPrinter) printNamespace(namespaceNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + namespace := topology.MustAccessObject(namespaceNode, &corev1.Namespace{}) + + metadata := namespace.ObjectMeta.DeepCopy() + metadata.Labels = nil + metadata.Annotations = nil + metadata.Name = "" + metadata.Namespace = "" + metadata.ManagedFields = nil + + pairs := []*DescriberKV{ + {Key: "Name", Value: namespace.GetName()}, + {Key: "Labels", Value: namespace.Labels}, + {Key: "Annotations", Value: namespace.Annotations}, + {Key: "Status", Value: &namespace.Status}, + } + + // DirectlyAttachedPolicies + policiesMap, err := directlyattachedpolicy.Access(namespaceNode) + if err != nil { + return err + } + policies := policymanager.ConvertPoliciesMapToSlice(policiesMap) + pairs = append(pairs, &DescriberKV{Key: "DirectlyAttachedPolicies", Value: convertPoliciesToRefsTable(policies, false)}) + + // Events + events, err := p.EventFetcher.FetchEventsFor(namespace) + if err != nil { + return err + } + pairs = append(pairs, &DescriberKV{Key: "Events", Value: convertEventsSliceToTable(events, p.Clock)}) + + Describe(w, pairs) + return nil +} diff --git a/pkg/cli/printer/namespace_test.go b/pkg/cli/printer/namespace_test.go new file mode 100644 index 00000000..4eaeff09 --- /dev/null +++ b/pkg/cli/printer/namespace_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestTablePrinter_printNamespace(t *testing.T) { + options := PrinterOptions{} + p := &TablePrinter{PrinterOptions: options} + out := &bytes.Buffer{} + + for _, ns := range testData(t)[common.NamespaceGK] { + p.printNamespace(ns, out) + p.Flush(out) + } + + wantOut := ` +NAME STATUS AGE +ns-1 Active +` + + got := common.MultiLine(out.String()) + want := common.MultiLine(strings.TrimPrefix(wantOut, "\n")) + + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } +} diff --git a/pkg/cli/printer/policies.go b/pkg/cli/printer/policies.go new file mode 100644 index 00000000..4e0d8e4e --- /dev/null +++ b/pkg/cli/printer/policies.go @@ -0,0 +1,186 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/klog/v2" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +func (p *TablePrinter) printPolicy(policyNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("Policy", w); err != nil { + return err + } + + if p.table == nil { + p.table = &Table{ + ColumnNames: []string{"NAME", "KIND", "TARGET NAME", "TARGET KIND", "POLICY TYPE", "AGE"}, + UseSeparator: false, + } + } + + var err error + var policy *policymanager.Policy + if policy, err = accessPolicyOrCRD[policymanager.Policy](policyNode, common.PolicyGK); err != nil { + return err + } + + policyType := "Direct" + if policy.IsInheritable() { + policyType = "Inherited" + } + + kind := fmt.Sprintf("%v.%v", policy.Unstructured.GroupVersionKind().Kind, policy.Unstructured.GroupVersionKind().Group) + + age := UnknownAge + creationTimestamp := policy.Unstructured.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + policy.Unstructured.GetName(), + kind, + policy.TargetRef.Name, + policy.TargetRef.Kind, + policyType, + age, + } + p.table.Rows = append(p.table.Rows, row) + + return nil +} + +func (p *TablePrinter) printPolicyCRD(policyCRDNode *topology.Node, w io.Writer) error { + if err := p.checkTypeChange("Policy", w); err != nil { + return err + } + + if p.table == nil { + p.table = &Table{ + ColumnNames: []string{"NAME", "POLICY TYPE", "SCOPE", "AGE"}, + UseSeparator: false, + } + } + + var err error + var policyCRD *policymanager.PolicyCRD + if policyCRD, err = accessPolicyOrCRD[policymanager.PolicyCRD](policyCRDNode, common.PolicyCRDGK); err != nil { + return err + } + + policyType := "Direct" + if policyCRD.IsInheritable() { + policyType = "Inherited" + } + + age := UnknownAge + creationTimestamp := policyCRD.CRD.GetCreationTimestamp() + if !creationTimestamp.IsZero() { + age = duration.HumanDuration(p.Clock.Since(creationTimestamp.Time)) + } + + row := []string{ + policyCRD.CRD.Name, + policyType, + string(policyCRD.CRD.Spec.Scope), + age, + } + p.table.Rows = append(p.table.Rows, row) + return nil +} + +func (p *DescriptionPrinter) printPolicy(policyNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + var err error + var policy *policymanager.Policy + if policy, err = accessPolicyOrCRD[policymanager.Policy](policyNode, common.PolicyGK); err != nil { + return err + } + + pairs := []*DescriberKV{ + {Key: "Name", Value: policy.Unstructured.GetName()}, + {Key: "Namespace", Value: policy.Unstructured.GetNamespace()}, + {Key: "Group", Value: policy.Unstructured.GroupVersionKind().Group}, + {Key: "Kind", Value: policy.Unstructured.GroupVersionKind().Kind}, + {Key: "Inherited", Value: fmt.Sprintf("%v", policy.IsInheritable())}, + {Key: "Spec", Value: policy.Spec()}, + } + + Describe(w, pairs) + return nil +} + +func (p *DescriptionPrinter) printPolicyCRD(policyCRDNode *topology.Node, w io.Writer) error { + if p.printSeparator { + fmt.Fprintf(w, "\n\n") + } + p.printSeparator = true + + var err error + var policyCRD *policymanager.PolicyCRD + if policyCRD, err = accessPolicyOrCRD[policymanager.PolicyCRD](policyCRDNode, common.PolicyCRDGK); err != nil { + return err + } + + crd := policyCRD.CRD + + metadata := crd.ObjectMeta.DeepCopy() + metadata.Labels = nil + metadata.Annotations = nil + metadata.Name = "" + metadata.Namespace = "" + + pairs := []*DescriberKV{ + {Key: "Name", Value: crd.Name}, + {Key: "Namespace", Value: crd.Namespace}, + {Key: "APIVersion", Value: crd.APIVersion}, + {Key: "Kind", Value: crd.Kind}, + {Key: "Labels", Value: crd.Labels}, + {Key: "Annotations", Value: crd.Annotations}, + {Key: "Metadata", Value: metadata}, + {Key: "Spec", Value: crd.Spec}, + {Key: "Status", Value: crd.Status}, + } + Describe(w, pairs) + return nil +} + +func accessPolicyOrCRD[T any](node *topology.Node, gk schema.GroupKind) (*T, error) { + rawData, ok := node.Metadata[gk.String()] + if !ok || rawData == nil { + klog.V(3).InfoS(fmt.Sprintf("no %v found in node", gk.String()), "node", node.GKNN()) + return nil, nil + } + data, ok := rawData.(*T) + if !ok { + return nil, fmt.Errorf("unable to perform type assertion to %v in node %v", gk.String(), node.GKNN()) + } + return data, nil +} diff --git a/pkg/cli/printer/policies_test.go b/pkg/cli/printer/policies_test.go new file mode 100644 index 00000000..12874408 --- /dev/null +++ b/pkg/cli/printer/policies_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestTablePrinter_printPolicy(t *testing.T) { + options := PrinterOptions{} + p := &TablePrinter{PrinterOptions: options} + out := &bytes.Buffer{} + + for _, ns := range testPoliciesData(t)[common.PolicyGK] { + p.printBackend(ns, out) + p.Flush(out) + } + + wantOut := ` +NAMESPACE NAME TYPE AGE +ns-1 policy-1 TimeoutPolicy +` + + got := common.MultiLine(out.String()) + want := common.MultiLine(strings.TrimPrefix(wantOut, "\n")) + + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } +} diff --git a/pkg/cli/printer/printer.go b/pkg/cli/printer/printer.go new file mode 100644 index 00000000..463b530e --- /dev/null +++ b/pkg/cli/printer/printer.go @@ -0,0 +1,206 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/utils/clock" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +type OutputFormat string + +const ( + OutputFormatWide OutputFormat = "wide" + OutputFormatJSON OutputFormat = "json" + OutputFormatYAML OutputFormat = "yaml" + OutputFormatGraph OutputFormat = "graph" + OutputFormatTable OutputFormat = "" +) + +func ValidateAndReturnOutputFormat(format string) (OutputFormat, error) { + switch format { + case "wide": + return OutputFormatWide, nil + case "json": + return OutputFormatJSON, nil + case "yaml": + return OutputFormatYAML, nil + case "graph": + return OutputFormatGraph, nil + case "": + return OutputFormatTable, nil + default: + var zero OutputFormat + return zero, fmt.Errorf("unknown format %s provided", format) + } +} + +func AllowedOutputFormatsForHelp() []string { + return []string{string(OutputFormatWide), string(OutputFormatJSON), string(OutputFormatYAML), string(OutputFormatGraph)} +} + +type PrinterOptions struct { //nolint:revive + OutputFormat OutputFormat + Description bool + Clock clock.Clock + EventFetcher eventFetcher +} + +type Printer interface { + PrintNode(node *topology.Node, w io.Writer) error + Flush(io.Writer) error +} + +func NewPrinter(options PrinterOptions) Printer { + switch { + case options.OutputFormat == OutputFormatJSON: + return NewJSONPrinter() + case options.OutputFormat == OutputFormatYAML: + return NewYAMLPrinter() + case options.Description: + return &DescriptionPrinter{PrinterOptions: options} + default: + return &TablePrinter{PrinterOptions: options} + } +} + +type TablePrinter struct { + PrinterOptions + + table *Table + unknownTablePrinter printers.ResourcePrinter + curType string +} + +func (p *TablePrinter) PrintNode(node *topology.Node, w io.Writer) error { + return parseAndPrint(node, w, p) +} + +func (p *TablePrinter) Flush(w io.Writer) error { + return p.checkTypeChange("", w) +} + +func (p *TablePrinter) printUnknown(node *topology.Node, w io.Writer) error { + if p.unknownTablePrinter == nil { + p.unknownTablePrinter = printers.NewTablePrinter(printers.PrintOptions{}) + } + return p.unknownTablePrinter.PrintObj(node.Object, w) +} + +func (p *TablePrinter) checkTypeChange(newType string, w io.Writer) error { + var err error + if p.curType != "" && p.curType != newType && p.table != nil { + err = p.table.Write(w, 0) + p.table = nil + } + p.curType = newType + return err +} + +type DescriptionPrinter struct { + PrinterOptions + + printSeparator bool +} + +func (p *DescriptionPrinter) PrintNode(node *topology.Node, w io.Writer) error { + return parseAndPrint(node, w, p) +} + +type typedPrinter interface { + printBackend(*topology.Node, io.Writer) error + printGatewayClass(*topology.Node, io.Writer) error + printGateway(*topology.Node, io.Writer) error + printHTTPRoute(*topology.Node, io.Writer) error + printNamespace(*topology.Node, io.Writer) error + printPolicy(*topology.Node, io.Writer) error + printPolicyCRD(*topology.Node, io.Writer) error + printUnknown(*topology.Node, io.Writer) error +} + +func parseAndPrint(node *topology.Node, w io.Writer, p typedPrinter) error { + if node.Metadata != nil && node.Metadata[common.PolicyGK.String()] != nil { + return p.printPolicy(node, w) + } + if node.Metadata != nil && node.Metadata[common.PolicyCRDGK.String()] != nil { + return p.printPolicyCRD(node, w) + } + + switch node.GKNN().GroupKind() { + case common.GatewayGK: + return p.printGateway(node, w) + case common.GatewayClassGK: + return p.printGatewayClass(node, w) + case common.HTTPRouteGK: + return p.printHTTPRoute(node, w) + case common.NamespaceGK: + return p.printNamespace(node, w) + case common.ServiceGK: + return p.printBackend(node, w) + default: + return p.printUnknown(node, w) + } +} + +func (p *DescriptionPrinter) Flush(io.Writer) error { return nil } + +func (p *DescriptionPrinter) printUnknown(node *topology.Node, w io.Writer) error { + printer := &printers.YAMLPrinter{} + return printer.PrintObj(node.Object, w) +} + +type JSONPrinter struct { + Delegate *printers.OmitManagedFieldsPrinter +} + +func NewJSONPrinter() *JSONPrinter { + return &JSONPrinter{ + Delegate: &printers.OmitManagedFieldsPrinter{ + Delegate: &printers.JSONPrinter{}, + }, + } +} + +func (p *JSONPrinter) PrintNode(node *topology.Node, w io.Writer) error { + return p.Delegate.PrintObj(node.Object, w) +} + +func (p *JSONPrinter) Flush(io.Writer) error { return nil } + +type YAMLPrinter struct { + Delegate *printers.OmitManagedFieldsPrinter +} + +func NewYAMLPrinter() *YAMLPrinter { + return &YAMLPrinter{ + Delegate: &printers.OmitManagedFieldsPrinter{ + Delegate: &printers.YAMLPrinter{}, + }, + } +} + +func (p *YAMLPrinter) PrintNode(node *topology.Node, w io.Writer) error { + return p.Delegate.PrintObj(node.Object, w) +} + +func (p *YAMLPrinter) Flush(io.Writer) error { return nil } diff --git a/pkg/cli/printer/types.go b/pkg/cli/printer/types.go new file mode 100644 index 00000000..c2eda2a7 --- /dev/null +++ b/pkg/cli/printer/types.go @@ -0,0 +1,5 @@ +package printer + +const ( + UnknownAge string = "" +) diff --git a/pkg/cli/printer/utils.go b/pkg/cli/printer/utils.go new file mode 100644 index 00000000..fc905d52 --- /dev/null +++ b/pkg/cli/printer/utils.go @@ -0,0 +1,258 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/klog/v2" + "k8s.io/utils/clock" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/policymanager" +) + +// DescriberKV stores key-value pairs that are used with Describing a resource. +type DescriberKV struct { + Key string + Value any +} + +const ( + // Default indentation for Tables that are printed in the Describe view. + defaultDescribeTableIndentSpaces = 2 +) + +// Describe writes the key-value paris to the writer. It handles things like +// properly writing special data types like Tables. +func Describe(w io.Writer, pairs []*DescriberKV) { + for _, pair := range pairs { + // If the Value is of type Table, it needs special handling. + if table, ok := pair.Value.(*Table); ok { + if len(table.Rows) == 0 { + fmt.Fprintf(w, "%v: \n", pair.Key) + } else { + fmt.Fprintf(w, "%v:\n", pair.Key) + _ = table.Write(w, defaultDescribeTableIndentSpaces) + } + continue + } + + // If Value is NOT a Table, it can be handled through the yaml Marshaller. + data := map[string]any{pair.Key: pair.Value} + b, err := yaml.Marshal(data) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal to yaml: %v\n", err) + os.Exit(1) + } + fmt.Fprint(w, string(b)) + } +} + +type Table struct { + ColumnNames []string + Rows [][]string + // UseSeparator indicates whether the header row and data rows will be + // separated through a separator. + UseSeparator bool +} + +// Write will write a formatted table to the writer. indent controls the +// number of spaces at the beginning of each row. +func (t *Table) Write(w io.Writer, indent int) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + // Print column names. + if len(t.ColumnNames) > 0 { + row := t.indentRow(t.ColumnNames, indent) + _, err := tw.Write([]byte(strings.Join(row, "\t") + "\n")) + if err != nil { + return err + } + } + + // Optionally print a separator between header row and data rows. + if t.UseSeparator { + row := make([]string, len(t.ColumnNames)) + for i, value := range t.ColumnNames { + row[i] = strings.Repeat("-", len(value)) + } + row = t.indentRow(row, indent) + _, err := tw.Write([]byte(strings.Join(row, "\t") + "\n")) + if err != nil { + return err + } + } + + // Print data rows. + for _, row := range t.Rows { + row = t.indentRow(row, indent) + _, err := tw.Write([]byte(strings.Join(row, "\t") + "\n")) + if err != nil { + return err + } + } + return tw.Flush() +} + +// indentRow will add 'indent' spaces to the beginning of the row. +func (t *Table) indentRow(row []string, indent int) []string { + if len(row) == 0 { + return row + } + + newRow := append([]string{}, row...) + newRow[0] = fmt.Sprintf("%s%s", strings.Repeat(" ", indent), newRow[0]) + return newRow +} + +func convertEventsSliceToTable(events []*corev1.Event, clock clock.Clock) *Table { + table := &Table{ + ColumnNames: []string{"Type", "Reason", "Age", "From", "Message"}, + UseSeparator: true, + } + for _, event := range events { + age := UnknownAge + if !event.FirstTimestamp.IsZero() { + age = duration.HumanDuration(clock.Since(event.FirstTimestamp.Time)) + } + + row := []string{ + event.Type, // Type + event.Reason, // Reason + age, // Age + event.Source.Component, // From + event.Message, // Message + } + table.Rows = append(table.Rows, row) + } + return table +} + +func convertPoliciesToRefsTable(policies []*policymanager.Policy, includeTarget bool) *Table { + table := &Table{ + ColumnNames: []string{"Type", "Name"}, + UseSeparator: true, + } + if includeTarget { + table.ColumnNames = append(table.ColumnNames, "Target Kind", "Target Name") + } + + for _, policy := range policies { + policyType := fmt.Sprintf("%v.%v", policy.Unstructured.GroupVersionKind().Kind, policy.Unstructured.GroupVersionKind().Group) + + policyName := policy.Unstructured.GetName() + if ns := policy.Unstructured.GetNamespace(); ns != "" { + policyName = fmt.Sprintf("%v/%v", ns, policyName) + } + + targetKind := policy.TargetRef.Kind + + targetName := policy.TargetRef.Name + if ns := policy.TargetRef.Namespace; ns != "" { + targetName = fmt.Sprintf("%v/%v", ns, targetName) + } + + row := []string{ + policyType, // Type + policyName, // Name + } + + if includeTarget { + row = append(row, + targetKind, // Target Kind + targetName, // Target Name + ) + } + + table.Rows = append(table.Rows, row) + } + return table +} + +func convertErrorsToString(errors []error) []string { + var result []string + for _, err := range errors { + result = append(result, err.Error()) + } + return result +} + +type eventFetcher interface { + FetchEventsFor(client.Object) ([]*corev1.Event, error) +} + +var _ eventFetcher = (*DefaultEventFetcher)(nil) + +type DefaultEventFetcher struct { + factory common.Factory +} + +func NewDefaultEventFetcher(factory common.Factory) *DefaultEventFetcher { + return &DefaultEventFetcher{factory: factory} +} + +func (d DefaultEventFetcher) FetchEventsFor(object client.Object) ([]*corev1.Event, error) { + eventGK := schema.GroupKind{Group: corev1.GroupName, Kind: "Event"} + + infos, err := d.factory.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + Flatten(). + AllNamespaces(true). + ResourceTypeOrNameArgs(true, []string{fmt.Sprintf("%v.%v", eventGK.Kind, eventGK.Group)}...). + FieldSelectorParam(fmt.Sprintf("involvedObject.uid=%v", string(object.GetUID()))). + ContinueOnError(). + Do(). + Infos() + if err != nil { + return nil, err + } + + var result []*corev1.Event + for _, info := range infos { + eventObj, ok := info.Object.(*corev1.Event) + + if !ok { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) + if err != nil { + klog.V(3).ErrorS(nil, err.Error(), "info.Object", info.Object) + return nil, fmt.Errorf("failed to convert runtime.Object to *v1.Event") + } + + eventObj = &corev1.Event{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj, eventObj); err != nil { + klog.V(3).ErrorS(nil, err.Error(), "info.Object", info.Object) + return nil, fmt.Errorf("failed to convert runtime.Object to *v1.Event") + } + } + + result = append(result, eventObj) + } + + return result, nil +} diff --git a/pkg/cli/printer/utils_test.go b/pkg/cli/printer/utils_test.go new file mode 100644 index 00000000..607d110b --- /dev/null +++ b/pkg/cli/printer/utils_test.go @@ -0,0 +1,158 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" +) + +func TestDescribe(t *testing.T) { + pairs := []*DescriberKV{ + {Key: "Key1", Value: "string"}, + {Key: "Key2", Value: []any{"list-string1", 1234, "list-string-3"}}, + { + Key: "Key3-with-nested-structures", + Value: map[string]any{ + "a": "b", + "d": map[string]any{ + "e": []string{"v1", "v2", "v3"}, + }, + "c": 123, + }, + }, + { + Key: "Key4-table", + Value: &Table{ + ColumnNames: []string{"col1", "col2", "col3"}, + Rows: [][]string{ + {"row1-a", "row1-b", "row1-c"}, + {"row2-a", "row2-b", "row2-c"}, + {"row3-a", "row3-b", "row3-c"}, + }, + UseSeparator: true, + }, + }, + } + + writable := &bytes.Buffer{} + Describe(writable, pairs) + + got := writable.String() + want := `Key1: string +Key2: +- list-string1 +- 1234 +- list-string-3 +Key3-with-nested-structures: + a: b + c: 123 + d: + e: + - v1 + - v2 + - v3 +Key4-table: + col1 col2 col3 + ---- ---- ---- + row1-a row1-b row1-c + row2-a row2-b row2-c + row3-a row3-b row3-c +` + if diff := cmp.Diff(common.MultiLine(want), common.MultiLine(got), common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", common.MultiLine(got), common.MultiLine(want), common.MultiLine(diff)) + } +} + +func TestTable_writeTable(t *testing.T) { + testcases := []struct { + name string + table *Table + indent int + want string + }{ + { + name: "without separator", + table: &Table{ + ColumnNames: []string{"Kind", "Name"}, + Rows: [][]string{ + {"HTTPRoute", "default/my-httproute"}, + {"TCPRoute", "ns2/my-tcproute"}, + }, + }, + indent: 0, + want: ` +Kind Name +HTTPRoute default/my-httproute +TCPRoute ns2/my-tcproute +`, + }, + { + name: "with separator", + table: &Table{ + ColumnNames: []string{"Kind", "Name"}, + Rows: [][]string{ + {"HTTPRoute", "default/my-httproute"}, + {"TCPRoute", "ns2/my-tcproute"}, + }, + UseSeparator: true, + }, + indent: 0, + want: ` +Kind Name +---- ---- +HTTPRoute default/my-httproute +TCPRoute ns2/my-tcproute +`, + }, + { + name: "with indent and separator", + table: &Table{ + ColumnNames: []string{"Kind", "Name"}, + Rows: [][]string{ + {"HTTPRoute", "default/my-httproute"}, + {"TCPRoute", "ns2/my-tcproute"}, + }, + UseSeparator: true, + }, + indent: 3, // We want 3 spaces at the start of each row. + want: ` + Kind Name + ---- ---- + HTTPRoute default/my-httproute + TCPRoute ns2/my-tcproute +`, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + writable := &bytes.Buffer{} + tc.table.Write(writable, tc.indent) + + got := common.MultiLine(writable.String()) + want := common.MultiLine(strings.TrimPrefix(tc.want, "\n")) + if diff := cmp.Diff(want, got, common.MultiLineTransformer); diff != "" { + t.Fatalf("Unexpected diff:\n\ngot =\n\n%v\n\nwant =\n\n%v\n\ndiff (-want, +got) =\n\n%v", got, want, common.MultiLine(diff)) + } + }) + } +} diff --git a/pkg/cli/topology/gateway/gateway.go b/pkg/cli/topology/gateway/gateway.go new file mode 100644 index 00000000..e3e7f4eb --- /dev/null +++ b/pkg/cli/topology/gateway/gateway.go @@ -0,0 +1,299 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/topology" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var ( + AllRelations = []*topology.Relation{ + GatewayParentGatewayClassRelation, + HTTPRouteParentGatewaysRelation, + HTTPRouteChildBackendRefsRelation, + GatewayNamespace, + HTTPRouteNamespace, + BackendNamespace, + } + + // GatewayParentGatewayClassRelation returns GatewayClass for the Gateway. + GatewayParentGatewayClassRelation = &topology.Relation{ + From: common.GatewayGK, + To: common.GatewayClassGK, + Name: "GatewayClass", + NeighborFunc: func(u *unstructured.Unstructured) []common.GKNN { + gateway := &gatewayv1.Gateway{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), gateway); err != nil { + panic(fmt.Sprintf("failed to convert unstructured Gateway to structured: %v", err)) + } + return []common.GKNN{{ + Group: common.GatewayClassGK.Group, + Kind: common.GatewayClassGK.Kind, + Name: string(gateway.Spec.GatewayClassName), + }} + }, + } + + // HTTPRouteParentGatewayRelation returns Gateways which the HTTPRoute is + // attached to. + HTTPRouteParentGatewaysRelation = &topology.Relation{ + From: common.HTTPRouteGK, + To: common.GatewayGK, + Name: "ParentRef", + NeighborFunc: func(u *unstructured.Unstructured) []common.GKNN { + httpRoute := &gatewayv1.HTTPRoute{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), httpRoute); err != nil { + panic(fmt.Sprintf("failed to convert unstructured HTTPRoute to structured: %v", err)) + } + result := []common.GKNN{} + for _, gatewayRef := range httpRoute.Spec.ParentRefs { + namespace := httpRoute.GetNamespace() + if namespace == "" { + namespace = metav1.NamespaceDefault + } + if gatewayRef.Namespace != nil { + namespace = string(*gatewayRef.Namespace) + } + + result = append(result, common.GKNN{ + Group: common.GatewayGK.Group, + Kind: common.GatewayGK.Kind, + Namespace: namespace, + Name: string(gatewayRef.Name), + }) + } + return result + }, + } + + // HTTPRouteChildBackendRefsRelation returns Backends which the HTTPRoute + // references. + HTTPRouteChildBackendRefsRelation = &topology.Relation{ + From: common.HTTPRouteGK, + To: common.ServiceGK, + Name: "BackendRef", + NeighborFunc: func(u *unstructured.Unstructured) []common.GKNN { + httpRoute := &gatewayv1.HTTPRoute{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), httpRoute); err != nil { + panic(fmt.Sprintf("failed to convert unstructured HTTPRoute to structured: %v", err)) + } + // Aggregate all BackendRefs + var backendRefs []gatewayv1.BackendObjectReference + for _, rule := range httpRoute.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + backendRefs = append(backendRefs, backendRef.BackendObjectReference) + } + for _, filter := range rule.Filters { + if filter.Type != gatewayv1.HTTPRouteFilterRequestMirror { + continue + } + if filter.RequestMirror == nil { + continue + } + backendRefs = append(backendRefs, filter.RequestMirror.BackendRef) + } + } + + // Convert each BackendRef to GKNN. GNKK does not use pointers and + // thus is easily comparable. + resultSet := make(map[common.GKNN]bool) + for _, backendRef := range backendRefs { + objRef := common.GKNN{ + Name: string(backendRef.Name), + // Assume namespace is unspecified in the backendRef and + // check later to override the default value. + Namespace: httpRoute.GetNamespace(), + } + if backendRef.Group != nil { + objRef.Group = string(*backendRef.Group) + } + if backendRef.Kind != nil { + objRef.Kind = string(*backendRef.Kind) + } else { + // Although for resources existing on the server, this value + // should have received a default before getting persisted. + // We still explicitly set this for the local analysis when + // the defaults do not get set automatically. + objRef.Kind = common.ServiceGK.Kind + } + if backendRef.Namespace != nil { + objRef.Namespace = string(*backendRef.Namespace) + } + resultSet[objRef] = true + } + + // Return unique objRefs + var result []common.GKNN + for objRef := range resultSet { + result = append(result, objRef) + } + return result + }, + } + + // GatewayNamespace returns the Namespace for the Gateway. + GatewayNamespace = &topology.Relation{ + From: common.GatewayGK, + To: common.NamespaceGK, + Name: "Namespace", + NeighborFunc: func(u *unstructured.Unstructured) []common.GKNN { + return []common.GKNN{{ + Group: common.NamespaceGK.Group, + Kind: common.NamespaceGK.Kind, + Name: u.GetNamespace(), + }} + }, + } + + // HTTPRouteNamespace returns the Namespace for the HTTPRoute. + HTTPRouteNamespace = &topology.Relation{ + From: common.HTTPRouteGK, + To: common.NamespaceGK, + Name: "Namespace", + NeighborFunc: func(u *unstructured.Unstructured) []common.GKNN { + return []common.GKNN{{ + Group: common.NamespaceGK.Group, + Kind: common.NamespaceGK.Kind, + Name: u.GetNamespace(), + }} + }, + } + + // BackendNamespace returns the Namespace for the Gateway. + BackendNamespace = &topology.Relation{ + From: common.ServiceGK, + To: common.NamespaceGK, + Name: "Namespace", + NeighborFunc: func(u *unstructured.Unstructured) []common.GKNN { + return []common.GKNN{{ + Group: common.NamespaceGK.Group, + Kind: common.NamespaceGK.Kind, + Name: u.GetNamespace(), + }} + }, + } +) + +type gatewayClassNode interface { + Gateways() map[common.GKNN]*topology.Node +} + +type gatewayNodeClassImpl struct { + node *topology.Node +} + +func GatewayClassNode(node *topology.Node) gatewayClassNode { //nolint:revive + return &gatewayNodeClassImpl{node: node} +} + +func (n *gatewayNodeClassImpl) Gateways() map[common.GKNN]*topology.Node { + return n.node.InNeighbors[GatewayParentGatewayClassRelation] +} + +type gatewayNode interface { + Namespace() *topology.Node + GatewayClass() *topology.Node + HTTPRoutes() map[common.GKNN]*topology.Node +} + +type gatewayNodeImpl struct { + node *topology.Node +} + +func GatewayNode(node *topology.Node) gatewayNode { //nolint:revive + return &gatewayNodeImpl{node: node} +} + +func (n *gatewayNodeImpl) Namespace() *topology.Node { + for _, namespaceNode := range n.node.OutNeighbors[GatewayNamespace] { + return namespaceNode + } + return nil +} + +func (n *gatewayNodeImpl) GatewayClass() *topology.Node { + for _, gatewayClassNode := range n.node.OutNeighbors[GatewayParentGatewayClassRelation] { + return gatewayClassNode + } + return nil +} + +func (n *gatewayNodeImpl) HTTPRoutes() map[common.GKNN]*topology.Node { + return n.node.InNeighbors[HTTPRouteParentGatewaysRelation] +} + +type httpRouteNode interface { + Namespace() *topology.Node + Gateways() map[common.GKNN]*topology.Node + Backends() map[common.GKNN]*topology.Node +} + +type httpRouteNodeImpl struct { + node *topology.Node +} + +func HTTPRouteNode(node *topology.Node) httpRouteNode { + return &httpRouteNodeImpl{node: node} +} + +func (n *httpRouteNodeImpl) Namespace() *topology.Node { + for _, namespaceNode := range n.node.OutNeighbors[HTTPRouteNamespace] { + return namespaceNode + } + return nil +} + +func (n *httpRouteNodeImpl) Gateways() map[common.GKNN]*topology.Node { + return n.node.OutNeighbors[HTTPRouteParentGatewaysRelation] +} + +func (n *httpRouteNodeImpl) Backends() map[common.GKNN]*topology.Node { + return n.node.OutNeighbors[HTTPRouteChildBackendRefsRelation] +} + +type backendNode interface { + Namespace() *topology.Node + HTTPRoutes() map[common.GKNN]*topology.Node +} + +type backendNodeImpl struct { + node *topology.Node +} + +func BackendNode(node *topology.Node) backendNode { + return &backendNodeImpl{node: node} +} + +func (n *backendNodeImpl) Namespace() *topology.Node { + for _, namespaceNode := range n.node.OutNeighbors[BackendNamespace] { + return namespaceNode + } + return nil +} + +func (n *backendNodeImpl) HTTPRoutes() map[common.GKNN]*topology.Node { + return n.node.InNeighbors[HTTPRouteChildBackendRefsRelation] +} diff --git a/pkg/cli/topology/gateway/graphviz.go b/pkg/cli/topology/gateway/graphviz.go new file mode 100644 index 00000000..8e73834c --- /dev/null +++ b/pkg/cli/topology/gateway/graphviz.go @@ -0,0 +1,136 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "bytes" + "context" + "log" + + graphviz "github.com/goccy/go-graphviz" + "github.com/goccy/go-graphviz/cgraph" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/flomesh-io/fsm/pkg/cli/topology" +) + +// TODO: +// - Show policy nodes. Attempt to group policy nodes along with their target +// nodes in a single subgraph so they get rendered closer together. +func ToDot(gwctlGraph *topology.Graph) ([]byte, error) { + ctx := context.TODO() + g, err := graphviz.New(ctx) + if err != nil { + return nil, err + } + cGraph, err := g.Graph() + if err != nil { + return nil, err + } + defer func() { + if err := cGraph.Close(); err != nil { + log.Fatal(err) + } + g.Close() + }() + cGraph.SetRankDir(cgraph.BTRank) + + cNodeMap := map[common.GKNN]*cgraph.Node{} + + // Create nodes. + for _, nodeMap := range gwctlGraph.Nodes { + for _, node := range nodeMap { + cNode, err := cGraph.CreateNodeByName(node.GKNN().String()) + if err != nil { + return nil, err + } + cNodeMap[node.GKNN()] = cNode + cNode.SetStyle(cgraph.FilledNodeStyle) + cNode.SetFillColor(nodeColor(node)) + + // Set the Node label + gk := node.GKNN().GroupKind() + if gk.Group == common.GatewayGK.Group { + gk.Group = "" + } + name := node.GKNN().NamespacedName().String() + if node.GKNN().Namespace == "" { + name = node.GKNN().Name + } + cNode.SetLabel(gk.String() + "\n" + name) + } + } + + // Create edges. + for fromNodeGKNN, cFromNode := range cNodeMap { + fromNode := gwctlGraph.Nodes[fromNodeGKNN.GroupKind()][fromNodeGKNN.NamespacedName()] + + for relation, outNodeMap := range fromNode.OutNeighbors { + for toNodeGKNN := range outNodeMap { + cToNode := cNodeMap[toNodeGKNN] + + // If this is an edge from an HTTPRoute to a Service, then + // reverse the direction of the edge (to affect the rank), and + // then reverse the display again to show the correct direction. + // The end result being that Services now get assigned the + // correct rank. + reverse := (fromNode.GKNN().GroupKind() == common.HTTPRouteGK && toNodeGKNN.GroupKind() == common.ServiceGK) || + (fromNode.GKNN().GroupKind() == common.GatewayGK && toNodeGKNN.GroupKind() == common.NamespaceGK) + u, v := cFromNode, cToNode + if reverse { + u, v = v, u + } + + e, err := cGraph.CreateEdgeByName(relation.Name, u, v) + if err != nil { + return nil, err + } + e.SetLabel(relation.Name) + if reverse { + e.SetDir(cgraph.BackDir) + } + // Create a dotted line for the relation to the namespace. + if toNodeGKNN.Kind == common.NamespaceGK.Kind { + e.SetStyle(cgraph.DottedEdgeStyle) + } + } + } + } + + var buf bytes.Buffer + if err := g.Render(ctx, cGraph, "dot", &buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func nodeColor(node *topology.Node) string { + switch node.GKNN().GroupKind() { + case common.NamespaceGK: + return "#d08770" + case common.GatewayClassGK: + return "#e5e9f0" + case common.GatewayGK: + return "#ebcb8b" + case common.HTTPRouteGK: + return "#a3be8c" + case common.ServiceGK: + return "#88c0d0" + } + return "#d8dee9" +} diff --git a/pkg/cli/topology/graph.go b/pkg/cli/topology/graph.go new file mode 100644 index 00000000..7dc255eb --- /dev/null +++ b/pkg/cli/topology/graph.go @@ -0,0 +1,377 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package topology + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/klog/v2" + + "github.com/flomesh-io/fsm/pkg/cli/common" +) + +const ( + DefaultGraphMaxDepth = 3 +) + +type Graph struct { + Nodes map[schema.GroupKind]map[types.NamespacedName]*Node + Sources []*Node + // MaxDepth represents the value of the maximum depth parameter used while + // performing the BFS from Source nodes. Note that terminal nodes can have a + // depth equal to MaxDepth+1. Any validations that need to happen should be + // done for nodes <= MaxDepth. This ensures that any external references + // from nodes <= MaxDepth can be validated. + MaxDepth int + Relations []*Relation +} + +func (g *Graph) AddNode(node *Node) { + klog.V(3).InfoS("AddNode", "node", node.GKNN()) + if g.Nodes == nil { + g.Nodes = make(map[schema.GroupKind]map[types.NamespacedName]*Node) + } + if g.Nodes[node.GKNN().GroupKind()] == nil { + g.Nodes[node.GKNN().GroupKind()] = make(map[types.NamespacedName]*Node) + } + g.Nodes[node.GKNN().GroupKind()][node.GKNN().NamespacedName()] = node +} + +func (g *Graph) DeleteNode(node *Node) { + klog.V(3).InfoS("DeleteNode", "node", node.GKNN()) + if g.Nodes == nil { + return + } + if g.Nodes[node.GKNN().GroupKind()] == nil { + return + } + delete(g.Nodes[node.GKNN().GroupKind()], node.GKNN().NamespacedName()) + if len(g.Nodes[node.GKNN().GroupKind()]) == 0 { + delete(g.Nodes, node.GKNN().GroupKind()) + } +} + +func (g *Graph) DeleteNodeUsingGKNN(nodeGKNN common.GKNN) { + klog.V(3).InfoS("DeleteNodeUsingGKNN", "nodeGKNN", nodeGKNN) + if !g.HasNode(nodeGKNN) { + return + } + g.DeleteNode(g.Nodes[nodeGKNN.GroupKind()][nodeGKNN.NamespacedName()]) +} + +func (g *Graph) HasNode(nodeGKNN common.GKNN) bool { + if g.Nodes == nil { + return false + } + if g.Nodes[nodeGKNN.GroupKind()] == nil { + return false + } + return g.Nodes[nodeGKNN.GroupKind()][nodeGKNN.NamespacedName()] != nil +} + +func (g *Graph) AddEdge(from *Node, to *Node, relation *Relation) { + klog.V(3).InfoS("AddEdge", "from", from.GKNN(), "to", to.GKNN()) + if from.OutNeighbors == nil { + from.OutNeighbors = make(map[*Relation]map[common.GKNN]*Node) + } + if from.OutNeighbors[relation] == nil { + from.OutNeighbors[relation] = make(map[common.GKNN]*Node) + } + from.OutNeighbors[relation][to.GKNN()] = to + + if to.InNeighbors == nil { + to.InNeighbors = make(map[*Relation]map[common.GKNN]*Node) + } + if to.InNeighbors[relation] == nil { + to.InNeighbors[relation] = make(map[common.GKNN]*Node) + } + to.InNeighbors[relation][from.GKNN()] = from +} + +func (g *Graph) RemoveEdge(from *Node, to *Node, relation *Relation) { + klog.V(3).InfoS("RemoveEdge", "from", from.GKNN(), "to", to.GKNN()) + delete(from.OutNeighbors[relation], to.GKNN()) + if len(from.OutNeighbors[relation]) == 0 { + delete(from.OutNeighbors, relation) + } + + delete(to.InNeighbors[relation], from.GKNN()) + if len(to.InNeighbors[relation]) == 0 { + delete(to.InNeighbors, relation) + } +} + +func (g *Graph) RemoveMetadata(category string) { + for gk := range g.Nodes { + for nn := range g.Nodes[gk] { + node := g.Nodes[gk][nn] + if node.Metadata != nil { + delete(node.Metadata, category) + } + } + } +} + +type Node struct { + Object *unstructured.Unstructured + InNeighbors map[*Relation]map[common.GKNN]*Node + OutNeighbors map[*Relation]map[common.GKNN]*Node + Depth int + Metadata map[string]any +} + +func (n *Node) GKNN() common.GKNN { + return common.GKNN{ + Group: n.Object.GroupVersionKind().Group, + Kind: n.Object.GroupVersionKind().Kind, + Namespace: n.Object.GetNamespace(), + Name: n.Object.GetName(), + } +} + +func MustAccessObject[T any](node *Node, concreteObj T) T { + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(node.Object.UnstructuredContent(), concreteObj); err != nil { + panic(fmt.Sprintf("failed to convert unstructured %v to structured: %v", node.GKNN().GroupKind(), err)) + } + return concreteObj +} + +type NeighborFunc func(*unstructured.Unstructured) []common.GKNN + +type Relation struct { + From schema.GroupKind + To schema.GroupKind + Name string + NeighborFunc NeighborFunc +} + +type Builder struct { + Sources []*unstructured.Unstructured + Relations []*Relation + Fetcher common.GroupKindFetcher + MaxDepth int +} + +func NewBuilder(fetcher common.GroupKindFetcher) *Builder { + return &Builder{ + Fetcher: fetcher, + MaxDepth: DefaultGraphMaxDepth, + } +} + +func (b *Builder) StartFrom(sources []*unstructured.Unstructured) *Builder { + b.Sources = sources + return b +} + +func (b *Builder) UseRelationship(relation *Relation) *Builder { + b.Relations = append(b.Relations, relation) + return b +} + +func (b *Builder) UseRelationships(relations []*Relation) *Builder { + b.Relations = append(b.Relations, relations...) + return b +} + +func (b *Builder) WithMaxDepth(maxDepth int) *Builder { + b.MaxDepth = maxDepth + return b +} + +func (b *Builder) Build() (*Graph, error) { + graph := &Graph{ + MaxDepth: b.MaxDepth, + Relations: b.Relations, + } + + for _, obj := range b.Sources { + node := &Node{Object: obj} + graph.Sources = append(graph.Sources, node) + graph.AddNode(node) + } + + // Perform BFS from source GroupKinds to figure out distinct other + // GroupKinds that need to be fetched. + allGroupKinds := b.determineUniqueGroupKinds() + if len(allGroupKinds) == 1 { + // This case means there's only one GroupKind which is the same as the + // Sources, so nothing needs to be done. + return graph, nil + } + // Fetch all relevant resources and add them to the graph as Nodes. At a + // later point, we will remove the resources which are not relevant. + const inf = 100000000 + for _, groupKind := range allGroupKinds { + klog.V(3).InfoS("Fetching resources", "groupKind", groupKind) + resources, err := b.Fetcher.Fetch(groupKind) + if err != nil { + return nil, err + } + for _, resource := range resources { + node := &Node{Object: resource, Depth: inf} + if !graph.HasNode(node.GKNN()) { + graph.AddNode(node) + } + } + } + + // Connect related resources. + for _, relation := range b.Relations { + for _, fromNode := range graph.Nodes[relation.From] { + for _, toNodeGKNN := range relation.NeighborFunc(fromNode.Object) { + if _, ok := graph.Nodes[toNodeGKNN.GroupKind()]; !ok { + continue + } + toNode := graph.Nodes[toNodeGKNN.GroupKind()][toNodeGKNN.NamespacedName()] + if toNode != nil { + graph.AddEdge(fromNode, toNode, relation) + } + } + } + } + + // Perform BFS. + + q := []*Node{} // q is a Queue used in the BFS. + + // Initialize the sources for the BFS + for _, source := range b.Sources { + gknn := (&Node{Object: source}).GKNN() + node := graph.Nodes[gknn.GroupKind()][gknn.NamespacedName()] + node.Depth = 0 + q = append(q, node) + } + + for len(q) != 0 { + u := q[0] + q = q[1:] + + if u.Depth+1 > b.MaxDepth+1 { + break + } + + // Don't expand from Namespaces to other resources. + if u.GKNN().GroupKind() == common.NamespaceGK { + continue + } + + // Don't expand from GatewayClasses if it is not the source node. + // TODO: Find appropriate ways to encode this with the + // topology/relations. + if u.GKNN().GroupKind() == common.GatewayClassGK && u.Depth != 0 { + continue + } + + allNeighbors := []map[*Relation]map[common.GKNN]*Node{ + u.InNeighbors, + u.OutNeighbors, + } + + // For vertex u, find all adjacent vertices v. + for _, neighbor := range allNeighbors { + for _, nodes := range neighbor { + for _, v := range nodes { + visited := v.Depth < inf + if visited { + continue + } + v.Depth = u.Depth + 1 + q = append(q, v) + } + } + } + } + + // BFS is now complete. Delete all Nodes which still have infinite depth. + for gk, nodes := range graph.Nodes { + for _, u := range nodes { + if u.Depth < inf { + continue + } + + // For each vertex u, find all vertices v which have an outgoing + // edge to u (ie. u has an incoming edge v -> u) + for relation, neighbors := range u.InNeighbors { + for _, v := range neighbors { + graph.RemoveEdge(v, u, relation) + } + } + + graph.DeleteNode(u) + } + + if len(nodes) == 0 { + delete(graph.Nodes, gk) + } + } + + return graph, nil +} + +func (b *Builder) determineUniqueGroupKinds() []schema.GroupKind { + result := []schema.GroupKind{} // result is the set of unique GroupKinds having depth <= b.MaxDepth + + q := []schema.GroupKind{} // q is a Queue used in the BFS. + visited := map[schema.GroupKind]bool{} + depth := map[schema.GroupKind]int{} + + // Initialize the sources for the BFS + for _, source := range b.Sources { + gk := source.GroupVersionKind().GroupKind() + if !visited[gk] { + result = append(result, gk) + visited[gk] = true + q = append(q, gk) + depth[gk] = 0 + } + } + + for len(q) != 0 { + u := q[0] + q = q[1:] + + // For vertex u, find all adjacent vertices v. + for _, relation := range b.Relations { + if relation.From != u && relation.To != u { + continue + } + v := relation.To + if u == v { + v = relation.From + } + if visited[v] { + continue + } + + visited[v] = true + depth[v] = depth[u] + 1 + q = append(q, v) + if depth[v] <= b.MaxDepth { + result = append(result, v) + } else { + return result + } + } + } + + return result +} diff --git a/pkg/cli/topology/graph_test.go b/pkg/cli/topology/graph_test.go new file mode 100644 index 00000000..71f12f6a --- /dev/null +++ b/pkg/cli/topology/graph_test.go @@ -0,0 +1,234 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package topology + +import ( + "fmt" + "testing" + + "github.com/flomesh-io/fsm/pkg/cli/common" + "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +func TestGraph_AddNode(t *testing.T) { + graph := &Graph{} + + node1 := &Node{Object: buildUnstructured(common.GKNN{Group: "1", Kind: "2", Namespace: "3", Name: "4"})} + node2 := &Node{Object: buildUnstructured(common.GKNN{Group: "1", Kind: "2", Namespace: "3", Name: "5"})} + node3 := &Node{Object: buildUnstructured(common.GKNN{Group: "1", Kind: "2", Namespace: "6", Name: "7"})} + node4 := &Node{Object: buildUnstructured(common.GKNN{Group: "1", Kind: "8", Namespace: "3", Name: "4"})} + + graph.AddNode(node1) + graph.AddNode(node2) + graph.AddNode(node3) + graph.AddNode(node4) + + wantGraph := &Graph{ + Nodes: map[schema.GroupKind]map[types.NamespacedName]*Node{ + {Group: "1", Kind: "2"}: { + {Namespace: "3", Name: "4"}: node1, + {Namespace: "3", Name: "5"}: node2, + {Namespace: "6", Name: "7"}: node3, + }, + {Group: "1", Kind: "8"}: { + {Namespace: "3", Name: "4"}: node4, + }, + }, + } + + if diff := cmp.Diff(wantGraph, graph); diff != "" { + t.Fatalf("Unexpected diff in graph after AddNode operations: (-want, +got)\n%v", diff) + } +} + +func TestGraph_AddEdge(t *testing.T) { + graph := &Graph{} + + gknn1 := common.GKNN{Group: "1", Kind: "2", Namespace: "3", Name: "4"} + node1 := &Node{Object: buildUnstructured(gknn1)} + + gknn2 := common.GKNN{Group: "1", Kind: "2", Namespace: "3", Name: "5"} + node2 := &Node{Object: buildUnstructured(gknn2)} + + gknn3 := common.GKNN{Group: "1", Kind: "2", Namespace: "6", Name: "7"} + node3 := &Node{Object: buildUnstructured(gknn3)} + + gknn4 := common.GKNN{Group: "1", Kind: "8", Namespace: "3", Name: "4"} + node4 := &Node{Object: buildUnstructured(gknn4)} + + childRelation := &Relation{Name: "child"} + parentRelation := &Relation{Name: "parent"} + + graph.AddEdge(node1, node2, childRelation) + graph.AddEdge(node1, node3, childRelation) + graph.AddEdge(node1, node4, parentRelation) + + wantNode1 := &Node{ + Object: buildUnstructured(gknn1), + OutNeighbors: map[*Relation]map[common.GKNN]*Node{ + childRelation: { + gknn2: node2, + gknn3: node3, + }, + parentRelation: { + gknn4: node4, + }, + }, + } + wantNode2 := &Node{ + Object: buildUnstructured(gknn2), + InNeighbors: map[*Relation]map[common.GKNN]*Node{ + childRelation: { + gknn1: node1, + }, + }, + } + cmpopts := []cmp.Option{cmp.Transformer("NeighborsTransformer", NeighborsTransformer)} + if diff := cmp.Diff(wantNode1, node1, cmpopts...); diff != "" { + t.Errorf("Unexpected diff in node1 after AddEdge operations: (-want, +got)\n%v", diff) + } + if diff := cmp.Diff(wantNode2, node2, cmpopts...); diff != "" { + t.Errorf("Unexpected diff in node2 after AddEdge operations: (-want, +got)\n%v", diff) + } +} + +func NeighborsTransformer(neighbors map[*Relation]map[common.GKNN]*Node) map[*Relation]map[common.GKNN]bool { + result := make(map[*Relation]map[common.GKNN]bool) + for relation, nodeMap := range neighbors { + result[relation] = make(map[common.GKNN]bool) + for nodeGKNN := range nodeMap { + result[relation][nodeGKNN] = true + } + } + return result +} + +func TestBuilder(t *testing.T) { + gknn1 := common.GKNN{Group: "1", Kind: "1", Namespace: "1", Name: "1"} + gknn2 := common.GKNN{Group: "2", Kind: "2", Namespace: "2", Name: "2"} + gknn3 := common.GKNN{Group: "3", Kind: "3", Namespace: "3", Name: "3"} + gknn4 := common.GKNN{Group: "4", Kind: "4", Namespace: "4", Name: "4"} + gknn5 := common.GKNN{Group: "5", Kind: "5", Namespace: "5", Name: "5"} // Unreachable + gknn6 := common.GKNN{Group: "6", Kind: "6", Namespace: "6", Name: "6"} // Unreachable + + relation2To1 := &Relation{ + From: gknn2.GroupKind(), + To: gknn1.GroupKind(), + Name: "gk2_to_gk1", + NeighborFunc: func(*unstructured.Unstructured) []common.GKNN { + return []common.GKNN{gknn1} + }, + } + relation2To3 := &Relation{ + From: gknn2.GroupKind(), + To: gknn3.GroupKind(), + Name: "gk2_to_gk3", + NeighborFunc: func(*unstructured.Unstructured) []common.GKNN { + return []common.GKNN{gknn3} + }, + } + relation4To3 := &Relation{ + From: gknn4.GroupKind(), + To: gknn3.GroupKind(), + Name: "gk4_to_gk3", + NeighborFunc: func(*unstructured.Unstructured) []common.GKNN { + return []common.GKNN{gknn3} + }, + } + relation4To5 := &Relation{ + From: gknn4.GroupKind(), + To: gknn5.GroupKind(), + Name: "gk4_to_gk5", + NeighborFunc: func(*unstructured.Unstructured) []common.GKNN { return nil }, + } + relation6To4 := &Relation{ + From: gknn6.GroupKind(), + To: gknn4.GroupKind(), + Name: "gk6_to_gk4", + NeighborFunc: func(*unstructured.Unstructured) []common.GKNN { return nil }, + } + + u1 := buildUnstructured(gknn1) + u2 := buildUnstructured(gknn2) + u3 := buildUnstructured(gknn3) + u4 := buildUnstructured(gknn4) + fakeFetcher := &fakeGroupKindFetcher{ + data: map[schema.GroupKind][]*unstructured.Unstructured{ + gknn1.GroupKind(): {u1}, + gknn2.GroupKind(): {u2}, + gknn3.GroupKind(): {u3}, + gknn4.GroupKind(): {u4}, + }, + } + + sources := []*unstructured.Unstructured{buildUnstructured(gknn3)} + graph, err := NewBuilder(fakeFetcher). + StartFrom(sources). + UseRelationship(relation2To1). + UseRelationship(relation2To3). + UseRelationship(relation4To3). + UseRelationship(relation4To5). + UseRelationship(relation6To4). + Build() + if err != nil { + t.Fatalf("Builder...Build() failed with error %v; want no errors", err) + } + + wantGraph := &Graph{} + node1 := &Node{Object: u1, Depth: 2} + node2 := &Node{Object: u2, Depth: 1} + node3 := &Node{Object: u3, Depth: 0} + node4 := &Node{Object: u4, Depth: 1} + wantGraph.AddNode(node1) + wantGraph.AddNode(node2) + wantGraph.AddNode(node3) + wantGraph.AddNode(node4) + wantGraph.AddEdge(node2, node1, relation2To1) + wantGraph.AddEdge(node2, node3, relation2To3) + wantGraph.AddEdge(node4, node3, relation4To3) + + if diff := cmp.Diff(wantGraph.Nodes, graph.Nodes); diff != "" { + t.Fatalf("Builder...Build(): Unexpected diff in graph: (-want, +got)\n%v", diff) + } +} + +func buildUnstructured(gknn common.GKNN) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": fmt.Sprintf("%v/v1", gknn.Group), + "kind": gknn.Kind, + "metadata": map[string]interface{}{ + "name": gknn.Name, + "namespace": gknn.Namespace, + }, + "spec": map[string]interface{}{ + "key": "value", + }, + }, + } +} + +type fakeGroupKindFetcher struct { + data map[schema.GroupKind][]*unstructured.Unstructured +} + +func (f *fakeGroupKindFetcher) Fetch(gk schema.GroupKind) ([]*unstructured.Unstructured, error) { + return f.data[gk], nil +} diff --git a/pkg/cli/topology/utils.go b/pkg/cli/topology/utils.go new file mode 100644 index 00000000..83c3dd0e --- /dev/null +++ b/pkg/cli/topology/utils.go @@ -0,0 +1,30 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package topology + +import ( + "sort" +) + +func SortedNodes(nodes []*Node) []*Node { + sort.Slice(nodes, func(i, j int) bool { + a := nodes[i].GKNN().String() + b := nodes[j].GKNN().String() + return a < b + }) + return nodes +}