From a7d522277e26dd9ea77d5a589c5eebd9a3dcf354 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Wed, 6 Mar 2024 14:31:16 +0100 Subject: [PATCH] feat(namespace): add the host based namespace (#157) --- cmd/namespace.go | 24 +++++- namespace/namespace_host.go | 117 +++++++++++++++++++++++++ namespace/namespace_host_test.go | 143 +++++++++++++++++++++++++++++++ namespace/namespace_test.go | 2 +- 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 namespace/namespace_host.go create mode 100644 namespace/namespace_host_test.go diff --git a/cmd/namespace.go b/cmd/namespace.go index 3f5be86..1c55303 100644 --- a/cmd/namespace.go +++ b/cmd/namespace.go @@ -67,11 +67,26 @@ func executeNamespace(cmd *cobra.Command, _ []string) error { } } + clearHosts, err := cmd.Flags().GetBool("clear-hosts") + if err != nil { + return fmt.Errorf("failed getting cli argument 'clear-hosts'; %w", err) + } + + var hosts []string + { + hosts, err = cmd.Flags().GetStringArray("host") + if err != nil { + return fmt.Errorf("failed to retrieve '--host' entry; %w", err) + } + } + trackInfo := deckformat.HistoryNewEntry("namespace") trackInfo["input"] = inputFilename trackInfo["output"] = outputFilename trackInfo["selectors"] = selectors.GetSources() trackInfo["path-prefix"] = pathPrefix + trackInfo["clear-host"] = clearHosts + trackInfo["hosts"] = hosts // do the work; read/prefix/write data, err := filebasics.DeserializeFile(inputFilename) @@ -82,7 +97,11 @@ func executeNamespace(cmd *cobra.Command, _ []string) error { yamlNode := jsonbasics.ConvertToYamlNode(data) err = namespace.Apply(yamlNode, selectors, pathPrefix, allowEmptySelectors) if err != nil { - log.Fatalf("failed to apply the namespace: %s", err) + log.Fatalf("failed to apply the path-based namespace: %s", err) + } + err = namespace.ApplyNamespaceHost(yamlNode, selectors, hosts, clearHosts, allowEmptySelectors) + if err != nil { + log.Fatalf("failed to apply the host-based namespace: %s", err) } data = jsonbasics.ConvertToJSONobject(yamlNode) @@ -151,4 +170,7 @@ func init() { "json-pointer identifying routes to update (can be specified more than once)") namespaceCmd.Flags().StringP("path-prefix", "p", "", "the path based namespace to apply") namespaceCmd.Flags().BoolP("allow-empty-selectors", "", false, "do not error out if the selectors return empty") + namespaceCmd.Flags().StringArrayP("host", "h", []string{}, + "hostname to add to the route.hosts property (can be specified more than once)") + namespaceCmd.Flags().BoolP("clear-hosts", "", false, "clears the route.hosts array (before adding the hosts)") } diff --git a/namespace/namespace_host.go b/namespace/namespace_host.go new file mode 100644 index 0000000..b4e913d --- /dev/null +++ b/namespace/namespace_host.go @@ -0,0 +1,117 @@ +package namespace + +import ( + "errors" + "fmt" + + "github.com/kong/go-apiops/logbasics" + "github.com/kong/go-apiops/yamlbasics" + "gopkg.in/yaml.v3" +) + +// ApplyNamespaceHost applies the namespace to the hosts field of the selected routes +// by adding the listed hosts if they ar not in the list already. +func ApplyNamespaceHost( + deckfile *yaml.Node, // the deckFile to operate on + selectors yamlbasics.SelectorSet, // the selectors to use to select the routes + hosts []string, // the hosts to add to the routes + clear bool, // if true, clear the hosts field before adding the hosts + allowEmptySelection bool, // if true, do not return an error if no routes are selected +) error { + if deckfile == nil { + panic("expected 'deckfile' to be non-nil") + } + + allRoutes := getAllRoutes(deckfile) + var targetRoutes yamlbasics.NodeSet + var err error + if selectors.IsEmpty() { + // no selectors, apply to all routes + targetRoutes = make(yamlbasics.NodeSet, len(allRoutes)) + copy(targetRoutes, allRoutes) + } else { + targetRoutes, err = selectors.Find(deckfile) + if err != nil { + return err + } + } + + var remainder yamlbasics.NodeSet + targetRoutes, remainder = allRoutes.Intersection(targetRoutes) // check for non-routes + if len(remainder) != 0 { + return fmt.Errorf("the selectors returned non-route entities; %d", len(remainder)) + } + if len(targetRoutes) == 0 { + if allowEmptySelection { + logbasics.Info("no routes matched the selectors, nothing to do") + return nil + } + return errors.New("no routes matched the selectors") + } + + return updateRouteHosts(targetRoutes, hosts, clear) +} + +// updateRouteHosts updates the hosts field of the provided routes. +// If clear is true, the hosts field is cleared before adding the hosts. +func updateRouteHosts(routes yamlbasics.NodeSet, hosts []string, clear bool) error { + for _, route := range routes { + if err := yamlbasics.CheckType(route, yamlbasics.TypeObject); err != nil { + logbasics.Info("ignoring route: " + err.Error()) + continue + } + + hostsValueNode := yamlbasics.GetFieldValue(route, "hosts") + if hostsValueNode == nil { + // the 'hosts' array doesn't exist + if len(hosts) == 0 { + // nothing to do since we're not adding anything + continue + } + // create an empty 'hosts' array, so we can add to it + hostsValueNode = yamlbasics.NewArray() + yamlbasics.SetFieldValue(route, "hosts", hostsValueNode) + } else { + // the 'hosts' array exists, check the type + if err := yamlbasics.CheckType(hostsValueNode, yamlbasics.TypeArray); err != nil { + logbasics.Info("ignoring route.hosts property: " + err.Error()) + continue + } + } + + if clear && len(hostsValueNode.Content) > 0 { + hostsValueNode.Content = make([]*yaml.Node, 0) + } + + if len(hosts) > 0 { + appendHosts(hostsValueNode, hosts) + } + } + + return nil +} + +// appendHosts appends the provided hosts to the hosts array, without duplicates. +func appendHosts(hostsValueNode *yaml.Node, hosts []string) { + if hostsValueNode == nil || hostsValueNode.Kind != yaml.SequenceNode { + panic("expected 'hostsValueNode' to be a sequence node") + } + if len(hosts) == 0 { + panic("expected 'hosts' to be non-nil and non-empty") + } + + for _, hostname := range hosts { + exists := false + for _, hostNameNode := range hostsValueNode.Content { + if hostNameNode.Value == hostname { + // already exists, skip + exists = true + break + } + } + if !exists { + // add the hostname to the array + hostsValueNode.Content = append(hostsValueNode.Content, yamlbasics.NewString(hostname)) + } + } +} diff --git a/namespace/namespace_host_test.go b/namespace/namespace_host_test.go new file mode 100644 index 0000000..f00abf6 --- /dev/null +++ b/namespace/namespace_host_test.go @@ -0,0 +1,143 @@ +package namespace_test + +import ( + "github.com/kong/go-apiops/namespace" + "github.com/kong/go-apiops/yamlbasics" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Host-Namespace", func() { + Describe("ApplyNamespaceHost", func() { + // clear == fasle/true + // hosts exists/not exists + // hosts has a name, has no name + // hosts has the namespace (eg adding duplicate) + Describe("clear hosts", func() { + It("clears the hosts", func() { + data := `{ + "routes": [ + { + "hosts": ["one", "two"] + } + ] + }` + deckfile := toYaml(data) + hosts := []string{"three"} + err := namespace.ApplyNamespaceHost(deckfile, yamlbasics.SelectorSet{}, hosts, true, false) + Expect(err).To(BeNil()) + Expect(toString(deckfile)).To(MatchJSON(`{ + "routes": [ + { + "hosts": ["three"] + } + ] + }`)) + }) + It("clears the hosts, no hosts", func() { + data := `{ + "routes": [ + { + "paths": [] + } + ] + }` + deckfile := toYaml(data) + hosts := []string{"three"} + err := namespace.ApplyNamespaceHost(deckfile, yamlbasics.SelectorSet{}, hosts, true, false) + Expect(err).To(BeNil()) + Expect(toString(deckfile)).To(MatchJSON(`{ + "routes": [ + { + "paths": [], + "hosts": ["three"] + } + ] + }`)) + }) + }) + }) + Describe("appends hosts", func() { + It("Route without hosts array", func() { + data := `{ + "routes": [ + { + "paths": [] + } + ] + }` + deckfile := toYaml(data) + hosts := []string{"three"} + err := namespace.ApplyNamespaceHost(deckfile, yamlbasics.SelectorSet{}, hosts, false, false) + Expect(err).To(BeNil()) + Expect(toString(deckfile)).To(MatchJSON(`{ + "routes": [ + { + "paths": [], + "hosts": ["three"] + } + ] + }`)) + }) + It("Route with empty hosts array", func() { + data := `{ + "routes": [ + { + "hosts": [] + } + ] + }` + deckfile := toYaml(data) + hosts := []string{"three"} + err := namespace.ApplyNamespaceHost(deckfile, yamlbasics.SelectorSet{}, hosts, false, false) + Expect(err).To(BeNil()) + Expect(toString(deckfile)).To(MatchJSON(`{ + "routes": [ + { + "hosts": ["three"] + } + ] + }`)) + }) + It("adds hosts", func() { + data := `{ + "routes": [ + { + "hosts": ["one", "two"] + } + ] + }` + deckfile := toYaml(data) + hosts := []string{"three"} + err := namespace.ApplyNamespaceHost(deckfile, yamlbasics.SelectorSet{}, hosts, false, false) + Expect(err).To(BeNil()) + Expect(toString(deckfile)).To(MatchJSON(`{ + "routes": [ + { + "hosts": ["one", "two", "three"] + } + ] + }`)) + }) + It("doesn't add duplicate hosts", func() { + data := `{ + "routes": [ + { + "hosts": ["one", "two"] + } + ] + }` + deckfile := toYaml(data) + hosts := []string{"one", "two", "three"} + err := namespace.ApplyNamespaceHost(deckfile, yamlbasics.SelectorSet{}, hosts, false, false) + Expect(err).To(BeNil()) + Expect(toString(deckfile)).To(MatchJSON(`{ + "routes": [ + { + "hosts": ["one", "two", "three"] + } + ] + }`)) + }) + }) +}) diff --git a/namespace/namespace_test.go b/namespace/namespace_test.go index 3f5e351..dd680be 100644 --- a/namespace/namespace_test.go +++ b/namespace/namespace_test.go @@ -29,7 +29,7 @@ func toString(data *yaml.Node) string { return string(out) } -var _ = Describe("Namespace", func() { +var _ = Describe("Path-Namespace", func() { Describe("CheckNamespace", func() { It("validates a plain namespace", func() { err := namespace.CheckNamespace("/prefix/")