Repository: kubernetes-sigs/ingress2gateway
Pull request #38 (2023-07-14 14:40:50)

Add ability to read yaml/json config file and convert ingresses to gateways
The flag for this is -input_file. Also added tests.

/kind feature

What this PR does / why we need it:
Enables the tool to take a file path as input to yaml/json file. Then convert Ingress resources in the file to Gateways.

release-note
Fixes: https://github.com/kubernetes-sigs/ingress2gateway/issues/36

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/ingress2gateway/pull/38#" title="Author self-approved">Treenhan</a>*
**Once this PR has been reviewed and has the lgtm label**, please assign [bowei](https://github.com/bowei) for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fingress2gateway).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/ingress2gateway/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["bowei"]} -->
k8s-ci-robot: Hi @Treenhan. Thanks for your PR.

I'm waiting for a [kubernetes-sigs](https://github.com/orgs/kubernetes-sigs/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://git.k8s.io/community/community-membership.md#member) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fingress2gateway).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

LiorLieberman: /ok-to-test
LiorLieberman: Thanks @Treenhan 
/lgtm

LiorLieberman: /assign @shaneutt 
robscott: Thanks @Treenhan!

/approve
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/ingress2gateway/pull/38#issuecomment-1642722537" title="Approved">robscott</a>*, *<a href="https://github.com/kubernetes-sigs/ingress2gateway/pull/38#" title="Author self-approved">Treenhan</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fingress2gateway).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/ingress2gateway/blob/main/OWNERS)~~ [robscott]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/cmd/print.go b/cmd/print.go
index d8d7d50..b142140 100644
--- a/cmd/print.go
+++ b/cmd/print.go
@@ -13,6 +13,7 @@ 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 cmd
 
 import (
@@ -31,6 +32,9 @@ var (
 	// Defaults to YAML.
 	outputFormat = "yaml"
 
+	// The path to the input yaml config file. Value assigned via --input_file flag
+	inputFile string
+
 	// The namespace used to query Gateway API objects. Value assigned via
 	// --namespace/-n flag.
 	// On absence, the current user active namespace is used.
@@ -55,7 +59,7 @@ var printCmd = &cobra.Command{
 		if err != nil {
 			return err
 		}
-		i2gw.Run(resourcePrinter, namespaceFilter)
+		i2gw.Run(resourcePrinter, namespaceFilter, inputFile)
 		return nil
 	},
 }
@@ -107,6 +111,9 @@ func init() {
 	printCmd.Flags().StringVarP(&outputFormat, "output", "o", "yaml",
 		fmt.Sprintf(`Output format. One of: (%s)`, strings.Join(allowedFormats, ", ")))
 
+	printCmd.Flags().StringVar(&inputFile, "input_file", "",
+		fmt.Sprintf(`Path to the manifest file. When set, the tool will read ingresses from the file instead of reading from the cluster. Supported files are yaml and json`))
+
 	printCmd.Flags().StringVarP(&namespace, "namespace", "n", "",
 		fmt.Sprintf(`If present, the namespace scope for this CLI request`))
 
diff --git a/cmd/root.go b/cmd/root.go
index 65dd64f..79ecef1 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -13,6 +13,7 @@ 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 cmd
 
 import (
diff --git a/main.go b/main.go
index e56df25..9093456 100644
--- a/main.go
+++ b/main.go
@@ -13,6 +13,7 @@ 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 main
 
 import "github.com/kubernetes-sigs/ingress2gateway/cmd"
diff --git a/pkg/i2gw/aggregator.go b/pkg/i2gw/aggregator.go
index 8fc953c..4cf3466 100644
--- a/pkg/i2gw/aggregator.go
+++ b/pkg/i2gw/aggregator.go
@@ -13,6 +13,7 @@ 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 i2gw
 
 import (
diff --git a/pkg/i2gw/ingress2gateway.go b/pkg/i2gw/ingress2gateway.go
index c4fa0b7..5238f5f 100644
--- a/pkg/i2gw/ingress2gateway.go
+++ b/pkg/i2gw/ingress2gateway.go
@@ -13,22 +13,29 @@ 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 i2gw
 
 import (
+	"bytes"
 	"context"
+	"errors"
 	"fmt"
+	"io"
 	"os"
 
 	networkingv1 "k8s.io/api/networking/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/validation/field"
+	kubeyaml "k8s.io/apimachinery/pkg/util/yaml"
 	"k8s.io/cli-runtime/pkg/printers"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/config"
 	gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"
 )
 
-func Run(printer printers.ResourcePrinter, namespace string) {
+func Run(printer printers.ResourcePrinter, namespace string, inputFile string) {
 	conf, err := config.GetConfig()
 	if err != nil {
 		fmt.Println("failed to get client config")
@@ -44,10 +51,18 @@ func Run(printer printers.ResourcePrinter, namespace string) {
 
 	ingressList := &networkingv1.IngressList{}
 
-	err = cl.List(context.Background(), ingressList)
-	if err != nil {
-		fmt.Printf("failed to list ingresses: %v\n", err)
-		os.Exit(1)
+	if inputFile != "" {
+		err = constructIngressesFromFile(ingressList, inputFile)
+		if err != nil {
+			fmt.Printf("failed to open input file: %v\n", err)
+			os.Exit(1)
+		}
+	} else {
+		err = cl.List(context.Background(), ingressList)
+		if err != nil {
+			fmt.Printf("failed to list ingresses: %v\n", err)
+			os.Exit(1)
+		}
 	}
 
 	if len(ingressList.Items) == 0 {
@@ -101,3 +116,77 @@ func outputResult(printer printers.ResourcePrinter, httpRoutes []gatewayv1beta1.
 		}
 	}
 }
+
+// extractObjectsFromReader extracts all objects from a reader,
+// which is created from YAML or JSON input files.
+// It retrieves all objects, including nested ones if they are contained within a list.
+func extractObjectsFromReader(reader io.Reader) ([]*unstructured.Unstructured, error) {
+	d := kubeyaml.NewYAMLOrJSONDecoder(reader, 4096)
+	var objs []*unstructured.Unstructured
+	for {
+		u := &unstructured.Unstructured{}
+		if err := d.Decode(&u); err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			return objs, fmt.Errorf("failed to unmarshal manifest: %w", err)
+		}
+		if u == nil {
+			continue
+		}
+		objs = append(objs, u)
+	}
+
+	finalObjs := []*unstructured.Unstructured{}
+	for _, obj := range objs {
+		tmpObjs := []*unstructured.Unstructured{}
+		if obj.IsList() {
+			err := obj.EachListItem(func(object runtime.Object) error {
+				unstructuredObj, ok := object.(*unstructured.Unstructured)
+				if ok {
+					tmpObjs = append(tmpObjs, unstructuredObj)
+					return nil
+				}
+				return fmt.Errorf("Resource list item has unexpected type")
+			})
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			tmpObjs = append(tmpObjs, obj)
+		}
+		finalObjs = append(finalObjs, tmpObjs...)
+	}
+
+	return finalObjs, nil
+}
+
+// constructIngressesFromFile reads the inputFile in either json/yaml formats,
+// then deserialize the file into Ingresses resources.
+// All ingresses will be pushed into the supplied IngressList for return.
+func constructIngressesFromFile(l *networkingv1.IngressList, inputFile string) error {
+	stream, err := os.ReadFile(inputFile)
+	if err != nil {
+		return err
+	}
+
+	reader := bytes.NewReader(stream)
+	objs, err := extractObjectsFromReader(reader)
+	if err != nil {
+		return err
+	}
+
+	for _, f := range objs {
+		if !f.GroupVersionKind().Empty() && f.GroupVersionKind().Kind == "Ingress" {
+			var i networkingv1.Ingress
+			err = runtime.DefaultUnstructuredConverter.
+				FromUnstructured(f.UnstructuredContent(), &i)
+			if err != nil {
+				return err
+			}
+			l.Items = append(l.Items, i)
+		}
+
+	}
+	return nil
+}


Test patch:
diff --git a/cmd/print_test.go b/cmd/print_test.go
index 7aff8d3..0a0258e 100644
--- a/cmd/print_test.go
+++ b/cmd/print_test.go
@@ -13,6 +13,7 @@ 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 cmd
 
 import (
diff --git a/pkg/i2gw/aggregator_test.go b/pkg/i2gw/aggregator_test.go
index c7b2fe6..624e1e8 100644
--- a/pkg/i2gw/aggregator_test.go
+++ b/pkg/i2gw/aggregator_test.go
@@ -13,6 +13,7 @@ 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 i2gw
 
 import (
diff --git a/pkg/i2gw/ingress2gateway_test.go b/pkg/i2gw/ingress2gateway_test.go
new file mode 100644
index 0000000..bfe925f
--- /dev/null
+++ b/pkg/i2gw/ingress2gateway_test.go
@@ -0,0 +1,126 @@
+/*
+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 i2gw
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	networkingv1 "k8s.io/api/networking/v1"
+	apiequality "k8s.io/apimachinery/pkg/api/equality"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func Test_constructIngressesFromFile(t *testing.T) {
+	iPrefix := networkingv1.PathTypePrefix
+	ingress1ClassName := "ingressClass1"
+	ingress2ClassName := "ingressClass2"
+
+	wantIngressList := []networkingv1.Ingress{
+		{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "Ingress",
+				APIVersion: "networking.k8s.io/v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{Name: "ingress1"},
+			Spec: networkingv1.IngressSpec{
+				IngressClassName: &ingress1ClassName,
+				Rules: []networkingv1.IngressRule{{
+					IngressRuleValue: networkingv1.IngressRuleValue{
+						HTTP: &networkingv1.HTTPIngressRuleValue{
+							Paths: []networkingv1.HTTPIngressPath{{
+								Path:     "/test-1",
+								PathType: &iPrefix,
+								Backend: networkingv1.IngressBackend{
+									Service: &networkingv1.IngressServiceBackend{
+										Name: "test1",
+										Port: networkingv1.ServiceBackendPort{
+											Number: 443,
+										},
+									},
+								},
+							}},
+						},
+					},
+				}},
+			},
+		},
+		{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "Ingress",
+				APIVersion: "networking.k8s.io/v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{Name: "ingress2"},
+			Spec: networkingv1.IngressSpec{
+				IngressClassName: &ingress2ClassName,
+				Rules: []networkingv1.IngressRule{{
+					IngressRuleValue: networkingv1.IngressRuleValue{
+						HTTP: &networkingv1.HTTPIngressRuleValue{
+							Paths: []networkingv1.HTTPIngressPath{{
+								Path:     "/test-2",
+								PathType: &iPrefix,
+								Backend: networkingv1.IngressBackend{
+									Service: &networkingv1.IngressServiceBackend{
+										Name: "test2",
+										Port: networkingv1.ServiceBackendPort{
+											Number: 80,
+										},
+									},
+								},
+							}},
+						},
+					},
+				}},
+			},
+		},
+	}
+
+	testCases := []struct {
+		name            string
+		filePath        string
+		wantIngressList []networkingv1.Ingress
+	}{{
+		name:            "Test yaml input file with multiple resources",
+		filePath:        "testdata/input-file.yaml",
+		wantIngressList: wantIngressList,
+	}, {
+		name:            "Test json input file with multiple resources",
+		filePath:        "testdata/input-file.json",
+		wantIngressList: wantIngressList,
+	},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			gotIngressList := &networkingv1.IngressList{}
+			err := constructIngressesFromFile(gotIngressList, tc.filePath)
+			if err != nil {
+				t.Errorf("Failed to open test file: %v", err)
+			}
+			compareIngressLists(t, gotIngressList, tc.wantIngressList)
+		})
+	}
+}
+
+func compareIngressLists(t *testing.T, gotIngressList *networkingv1.IngressList, wantIngressList []networkingv1.Ingress) {
+	for i, got := range gotIngressList.Items {
+		want := wantIngressList[i]
+		if !apiequality.Semantic.DeepEqual(got, want) {
+			t.Errorf("Expected Ingress %d to be %+v\n Got: %+v\n Diff: %s", i, want, got, cmp.Diff(want, got))
+		}
+	}
+}
diff --git a/pkg/i2gw/testdata/input-file.json b/pkg/i2gw/testdata/input-file.json
new file mode 100644
index 0000000..17b6561
--- /dev/null
+++ b/pkg/i2gw/testdata/input-file.json
@@ -0,0 +1,121 @@
+{
+    "apiVersion": "v1",
+    "kind": "List",
+    "items": [
+        {
+            "apiVersion": "apps/v1",
+            "kind": "Deployment",
+            "metadata": {
+                "name": "nginx-deployment"
+            },
+            "spec": {
+                "replicas": 3,
+                "selector": {
+                    "matchLabels": {
+                        "app": "nginx"
+                    }
+                },
+                "template": {
+                    "metadata": {
+                        "labels": {
+                            "app": "nginx"
+                        }
+                    },
+                    "spec": {
+                        "containers": [
+                            {
+                                "name": "nginx",
+                                "image": "nginx:1.14.2",
+                                "ports": [
+                                    {
+                                        "containerPort": 80
+                                    }
+                                ]
+                            }
+                        ]
+                    }
+                }
+            }
+        },
+        {
+            "apiVersion": "v1",
+            "kind": "Pod",
+            "metadata": {
+                "name": "nginx"
+            },
+            "spec": {
+                "containers": [
+                    {
+                        "name": "nginx",
+                        "image": "nginx:1.14.2",
+                        "ports": [
+                            {
+                                "containerPort": 80
+                            }
+                        ]
+                    }
+                ]
+            }
+        },
+        {
+            "apiVersion": "networking.k8s.io/v1",
+            "kind": "Ingress",
+            "metadata": {
+                "name": "ingress1"
+            },
+            "spec": {
+                "ingressClassName": "ingressClass1",
+                "rules": [
+                    {
+                        "http": {
+                            "paths": [
+                                {
+                                    "path": "/test-1",
+                                    "pathType": "Prefix",
+                                    "backend": {
+                                        "service": {
+                                            "name": "test1",
+                                            "port": {
+                                                "number": 443
+                                            }
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                ]
+            }
+        },
+        {
+            "apiVersion": "networking.k8s.io/v1",
+            "kind": "Ingress",
+            "metadata": {
+                "name": "ingress2"
+            },
+            "spec": {
+                "ingressClassName": "ingressClass2",
+                "rules": [
+                    {
+                        "http": {
+                            "paths": [
+                                {
+                                    "path": "/test-2",
+                                    "pathType": "Prefix",
+                                    "backend": {
+                                        "service": {
+                                            "name": "test2",
+                                            "port": {
+                                                "number": 80
+                                            }
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                ]
+            }
+        }
+    ]
+}
diff --git a/pkg/i2gw/testdata/input-file.yaml b/pkg/i2gw/testdata/input-file.yaml
new file mode 100644
index 0000000..0bdc77b
--- /dev/null
+++ b/pkg/i2gw/testdata/input-file.yaml
@@ -0,0 +1,64 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: nginx
+  template:
+    metadata:
+      labels:
+        app: nginx
+    spec:
+      containers:
+      - name: nginx
+        image: nginx:1.14.2
+        ports:
+        - containerPort: 80
+---
+apiVersion: v1 
+kind: Pod 
+metadata: 
+  name: nginx 
+spec: 
+  containers: 
+  - name: nginx 
+    image: nginx:1.14.2 
+    ports: 
+    - containerPort: 80
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: ingress1
+spec:
+  ingressClassName: ingressClass1
+  rules:
+  - http:
+      paths:
+      - path: /test-1
+        pathType: Prefix
+        backend:
+          service:
+            name: test1
+            port:
+              number: 443
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: ingress2
+spec:
+  ingressClassName: ingressClass2
+  rules:
+  - http:
+      paths:
+      - path: /test-2
+        pathType: Prefix
+        backend:
+          service:
+            name: test2
+            port:
+              number: 80