Repository: iter8-tools/iter8
Pull request #1328 (2022-08-31 20:28:52)

AutoX: watch resources
Replace #1319 


Patch:
diff --git a/autox/config.go b/autox/config.go
new file mode 100644
index 00000000..98d4876e
--- /dev/null
+++ b/autox/config.go
@@ -0,0 +1,99 @@
+package autox
+
+// config.go - reading of configurtion (list of resources/namespaces to watch)
+
+import (
+	"io/ioutil"
+
+	"github.com/iter8-tools/iter8/base/log"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	"sigs.k8s.io/yaml"
+)
+
+// resourceConfig is the configuration that identifies the resources to watch and in which namespaces.
+type resourceConfig struct {
+	// Namespaces is list of namespaces to watch
+	Namespaces []string `yaml:"namespaces,omitempty"`
+
+	// Resources is list of resoure types that should be watched in the namespaces
+	Resources []schema.GroupVersionResource `yaml:"resources,omitempty"`
+}
+
+// chart points to a particular Helm chart
+type chart struct {
+	// Repo is the repo of the Helm chart
+	Repo string `yaml:"repo"`
+
+	// Name is the name of the Helm chart
+	Name string `yaml:"name"`
+
+	// ValuesFileURL is the URL to the values file of the Helm chart
+	ValuesFileURL string `yaml:"valuesFileURL"`
+}
+
+type chartGroup struct {
+	Name string `yaml:"name"`
+
+	Charts []chart `yaml:"charts"`
+}
+
+type chartGroupConfig []chartGroup
+
+// readResourceConfig reads yaml config file and converts to a resourceConfig object
+func readResourceConfig(fn string) (config resourceConfig) {
+	// empty configuration
+	config = resourceConfig{}
+
+	yfile, err := ioutil.ReadFile(fn)
+	if err != nil {
+		log.Logger.Warnf("unable to read configuration file %s: %s", fn, err.Error())
+		return config // empty configuration
+	}
+
+	log.Logger.Debugf("read configuration\n%s", string(yfile))
+
+	err = yaml.Unmarshal(yfile, &config)
+	if err != nil {
+		log.Logger.Warnf("invalid configuration file %s: %s", fn, err.Error())
+		return config // empty configuration
+	}
+
+	if len(config.Namespaces) == 0 {
+		log.Logger.Warn("not watching any namespaces - configuration error?")
+	}
+	if len(config.Resources) == 0 {
+		log.Logger.Warn("not watching any resources - configuration error?")
+	}
+
+	return config
+}
+
+// readChartGroupConfig reads yaml chart group config file and converts to a chartGroupConfig object
+func readChartGroupConfig(fn string) (config chartGroupConfig) {
+	// empty configuration
+	config = chartGroupConfig{}
+
+	yfile, err := ioutil.ReadFile(fn)
+	if err != nil {
+		log.Logger.Warnf("unable to read configuration file %s: %s", fn, err.Error())
+		return config // empty configuration
+	}
+
+	log.Logger.Debugf("read configuration\n%s", string(yfile))
+
+	err = yaml.Unmarshal(yfile, &config)
+	if err != nil {
+		log.Logger.Warnf("invalid configuration file %s: %s", fn, err.Error())
+		return config // empty configuration
+	}
+
+	// if len(config.Namespaces) == 0 {
+	// 	log.Logger.Warn("not watching any namespaces - configuration error?")
+	// }
+	// if len(config.Resources) == 0 {
+	// 	log.Logger.Warn("not watching any resources - configuration error?")
+	// }
+
+	return config
+}
diff --git a/autox/doc.go b/autox/doc.go
new file mode 100644
index 00000000..bbdb56e5
--- /dev/null
+++ b/autox/doc.go
@@ -0,0 +1,2 @@
+// Package autox is the entry point for the autoX controller.
+package autox
diff --git a/autox/informer.go b/autox/informer.go
new file mode 100644
index 00000000..60b4a57e
--- /dev/null
+++ b/autox/informer.go
@@ -0,0 +1,51 @@
+package autox
+
+// informer.go - informer(s) to watch desired resources/namespaces
+
+import (
+	"github.com/iter8-tools/iter8/base/log"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/dynamic/dynamicinformer"
+	"k8s.io/client-go/tools/cache"
+)
+
+var addObject = func(obj interface{}) {
+	log.Logger.Debug("Add:", obj)
+}
+
+var updateObject = func(oldObj, obj interface{}) {
+	log.Logger.Debug("Update:", oldObj, obj)
+}
+
+var deleteObject = func(obj interface{}) {
+	log.Logger.Debug("Delete:", obj)
+}
+
+type iter8Watcher struct {
+	factories map[string]dynamicinformer.DynamicSharedInformerFactory
+}
+
+func newIter8Watcher(resourceTypes []schema.GroupVersionResource, namespaces []string, groupConfig chartGroupConfig) *iter8Watcher {
+	w := &iter8Watcher{
+		factories: map[string]dynamicinformer.DynamicSharedInformerFactory{},
+	}
+	// for each namespace, resource type configure Informer
+	for _, ns := range namespaces {
+		w.factories[ns] = dynamicinformer.NewFilteredDynamicSharedInformerFactory(k8sClient.dynamicClient, 0, ns, nil)
+		for _, gvr := range resourceTypes {
+			informer := w.factories[ns].ForResource(gvr)
+			informer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+				AddFunc:    addObject,
+				UpdateFunc: updateObject,
+				DeleteFunc: deleteObject,
+			})
+		}
+	}
+	return w
+}
+
+func (watcher *iter8Watcher) start(stopChannel chan struct{}) {
+	for _, f := range watcher.factories {
+		f.Start(stopChannel)
+	}
+}
diff --git a/autox/k8sclient.go b/autox/k8sclient.go
new file mode 100644
index 00000000..724e86d6
--- /dev/null
+++ b/autox/k8sclient.go
@@ -0,0 +1,77 @@
+package autox
+
+import (
+	"errors"
+
+	"github.com/iter8-tools/iter8/base/log"
+
+	"helm.sh/helm/v3/pkg/cli"
+
+	// Import to initialize client auth plugins.
+	_ "k8s.io/client-go/plugin/pkg/client/auth"
+
+	"k8s.io/client-go/dynamic"
+	"k8s.io/client-go/kubernetes"
+)
+
+// k8sClient is a global variable. Before use, it must be assigned and initalized
+var k8sClient *kubeClient = newKubeClient(cli.New())
+
+// kubeClient embeds Kube configuration, and
+// enables interaction with a Kubernetes cluster through Kube APIs
+type kubeClient struct {
+	// EnvSettings provides generic Kubernetes options
+	*cli.EnvSettings
+
+	// clientset enables interaction with a Kubernetes cluster using structured types
+	clientset kubernetes.Interface
+
+	// dynamicClient enables unstructured interaction with a Kubernetes cluster
+	dynamicClient dynamic.Interface
+}
+
+func newKubeClient(s *cli.EnvSettings) *kubeClient {
+	return &kubeClient{
+		EnvSettings: s,
+		// default other fields
+	}
+}
+
+// init initializes the Kubernetes clientset
+func (c *kubeClient) init() (err error) {
+	if c.dynamicClient == nil {
+		// get rest config
+		restConfig, err := c.EnvSettings.RESTClientGetter().ToRESTConfig()
+		if err != nil {
+			e := errors.New("unable to get Kubernetes REST config")
+			log.Logger.WithStackTrace(err.Error()).Error(e)
+			return e
+		}
+
+		// get clientset
+		c.clientset, err = kubernetes.NewForConfig(restConfig)
+		if err != nil {
+			e := errors.New("unable to get Kubernetes clientset")
+			log.Logger.WithStackTrace(err.Error()).Error(e)
+			return e
+		}
+
+		// get dynamic client
+		c.dynamicClient, err = dynamic.NewForConfig(restConfig)
+		if err != nil {
+			e := errors.New("unable to get Kubernetes dynamic client")
+			log.Logger.WithStackTrace(err.Error()).Error(e)
+			return e
+		}
+	}
+
+	return nil
+}
+
+// func (c *kubeClient) typed() kubernetes.Interface {
+// 	return c.clientset
+// }
+
+func (c *kubeClient) dynamic() dynamic.Interface {
+	return c.dynamicClient
+}
diff --git a/autox/watcher.go b/autox/watcher.go
new file mode 100644
index 00000000..f6ef0fce
--- /dev/null
+++ b/autox/watcher.go
@@ -0,0 +1,42 @@
+package autox
+
+import (
+	"os"
+
+	"github.com/iter8-tools/iter8/base/log"
+
+	_ "k8s.io/client-go/plugin/pkg/client/auth"
+)
+
+const (
+	// Name of environment variable with file path to configuration yaml file
+	RESOURCE_CONFIG_ENV    = "RESOURCE_CONFIG"
+	CHART_GROUP_CONFIG_ENV = "CHART_GROUP_CONFIG"
+)
+
+// Start is entry point to configure services and start them
+func Start(stopCh chan struct{}) {
+	// initialize kubernetes driver
+	k8sClient.init()
+
+	// read resource config (resources and namespaces to watch)
+	resourceConfigFile, ok := os.LookupEnv(RESOURCE_CONFIG_ENV)
+	if !ok {
+		log.Logger.Fatal("resource configuration file is required")
+	}
+
+	// read group config (apps and helm charts to install)
+	chartGroupConfigFile, ok := os.LookupEnv(CHART_GROUP_CONFIG_ENV)
+	if !ok {
+		log.Logger.Fatal("group configuration file is required")
+	}
+
+	// set up resource watching as defined by config
+	resourceConfig := readResourceConfig(resourceConfigFile)
+	chartGroupConfig := readChartGroupConfig(chartGroupConfigFile)
+
+	log.Logger.Debug("chartGroupConfig:", chartGroupConfig)
+
+	w := newIter8Watcher(resourceConfig.Resources, resourceConfig.Namespaces, chartGroupConfig)
+	go w.start(stopCh)
+}
diff --git a/cmd/autox.go b/cmd/autox.go
new file mode 100644
index 00000000..ac2b6f2c
--- /dev/null
+++ b/cmd/autox.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/iter8-tools/iter8/autox"
+	"github.com/spf13/cobra"
+)
+
+// autoxDesc is the description of autox cmd
+const autoxDesc = `
+Run the Iter8 autoX controller.
+	iter8 autox
+`
+
+// newAutoXCmd creates the autox command
+func newAutoXCmd() *cobra.Command {
+	// actor := ia.NewAutoXOpts(kd)
+
+	cmd := &cobra.Command{
+		Use:   "autox",
+		Short: "Start the Iter8 autoX controller",
+		Long:  autoxDesc,
+		RunE: func(_ *cobra.Command, _ []string) error {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			autox.Start(stopCh)
+
+			sigCh := make(chan os.Signal, 1)
+			signal.Notify(sigCh, syscall.SIGTERM, os.Interrupt)
+			<-sigCh
+
+			return nil
+		},
+		SilenceUsage: true,
+		Hidden:       true,
+	}
+	return cmd
+}
+
+// initialize with assert
+func init() {
+	rootCmd.AddCommand(newAutoXCmd())
+}
diff --git a/go.mod b/go.mod
index c6144868..5eb0fe19 100644
--- a/go.mod
+++ b/go.mod
@@ -47,4 +47,4 @@ require (
 	k8s.io/apimachinery v0.24.2
 	k8s.io/client-go v0.24.2
 	sigs.k8s.io/yaml v1.3.0
-)
+)
\ No newline at end of file


Test patch:
diff --git a/autox/config_test.go b/autox/config_test.go
new file mode 100644
index 00000000..0800e942
--- /dev/null
+++ b/autox/config_test.go
@@ -0,0 +1,59 @@
+package autox
+
+import (
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// utility method
+func completePath(prefix string, suffix string) string {
+	_, filename, _, _ := runtime.Caller(1) // one step up the call stack
+	return filepath.Join(filepath.Dir(filename), prefix, suffix)
+}
+
+func TestReadResourceConfig(t *testing.T) {
+	for _, tt := range []struct {
+		name          string
+		file          string
+		numNamespaces int
+		numResources  int
+	}{
+		{"empty", "config.empty.yaml", 0, 0},
+		{"invalid", "config.invalid.yaml", 0, 0},
+		{"garbage", "config.garbage.yaml", 0, 0},
+		{"nofile", "config.nofile.yaml", 0, 0},
+		{"nonamespaces", "resource_config.nonamespaces.yaml", 0, 1},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			c := readResourceConfig(completePath("../testdata/autox_inputs", tt.file))
+			assert.Equal(t, tt.numNamespaces, len(c.Namespaces))
+			assert.Equal(t, tt.numResources, len(c.Resources))
+		})
+	}
+}
+
+func TestReadGroupChartConfig(t *testing.T) {
+	for _, tt := range []struct {
+		name           string
+		file           string
+		numChartGroups int
+	}{
+		{"empty", "config.empty.yaml", 0},
+		{"invalid", "config.invalid.yaml", 0},
+		{"garbage", "config.garbage.yaml", 0},
+		{"nofile", "config.nofile.yaml", 0},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			c := readChartGroupConfig(completePath("../testdata/autox_inputs", tt.file))
+			assert.Equal(t, tt.numChartGroups, len(c))
+
+			c = readChartGroupConfig(completePath("../testdata/autox_inputs", "group_config.example.yaml"))
+			assert.Equal(t, 2, len(c))
+			assert.Equal(t, 2, len(c[0].Charts))
+			assert.Equal(t, 1, len(c[1].Charts))
+		})
+	}
+}
diff --git a/autox/informer_test.go b/autox/informer_test.go
new file mode 100644
index 00000000..6bac8654
--- /dev/null
+++ b/autox/informer_test.go
@@ -0,0 +1,143 @@
+package autox
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	// abnapp "github.com/iter8-tools/iter8/abn/application"
+	// "github.com/iter8-tools/iter8/abn/k8sclient"
+
+	"github.com/iter8-tools/iter8/base/log"
+	"github.com/stretchr/testify/assert"
+	"helm.sh/helm/v3/pkg/cli"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	trackAnnotation = "iter8.tools/track"
+	newLabel        = "app.kubernetes.io/name"
+	versionLabel    = "app.kubernetes.io/version"
+	readyAnnotation = "iter8.tools/ready"
+	iter8Annotation = "iter8.tools/abn"
+	iter8Label      = "iter8.tools/abn"
+)
+
+// Check to see if add, update, delete handlers from the watcher are properly invoked
+func TestWatcher(t *testing.T) {
+	addObjectInvocations := 0
+	updateObjectInvocations := 0
+	deleteObjectInvocations := 0
+
+	// Overwrite original handlers
+	addObject = func(obj interface{}) {
+		log.Logger.Debug("Add:", obj)
+		addObjectInvocations++
+	}
+	updateObject = func(oldObj, obj interface{}) {
+		log.Logger.Debug("Update:", oldObj, obj)
+		updateObjectInvocations++
+	}
+	deleteObject = func(obj interface{}) {
+		log.Logger.Debug("Delete:", obj)
+		deleteObjectInvocations++
+	}
+
+	gvr := schema.GroupVersionResource{
+		Group:    "apps",
+		Version:  "v1",
+		Resource: "deployments",
+	}
+	namespace := "default"
+	application := "demo"
+	version := "v1"
+	track := ""
+
+	// define and start watcher
+	k8sClient = newFakeKubeClient(cli.New())
+	w := newIter8Watcher(
+		[]schema.GroupVersionResource{gvr},
+		[]string{namespace},
+		chartGroupConfig{},
+	)
+	assert.NotNil(t, w)
+	done := make(chan struct{})
+	defer close(done)
+	w.start(done)
+
+	// create object; no track defined
+	assert.Equal(t, 0, addObjectInvocations)
+	createdObj, err := k8sClient.dynamic().
+		Resource(gvr).Namespace(namespace).
+		Create(
+			context.TODO(),
+			newUnstructuredDeployment(namespace, application, version, track),
+			metav1.CreateOptions{},
+		)
+	assert.NoError(t, err)
+	assert.NotNil(t, createdObj)
+
+	// give handler time to execute
+	assert.Eventually(t, func() bool { return assert.Equal(t, 1, addObjectInvocations) }, 5*time.Second, time.Second)
+
+	// update object with track
+	assert.Equal(t, 0, updateObjectInvocations)
+	track = "track"
+	(createdObj.Object["metadata"].(map[string]interface{}))["annotations"].(map[string]interface{})[trackAnnotation] = track
+	updatedObj, err := k8sClient.dynamic().
+		Resource(gvr).Namespace(namespace).
+		Update(
+			context.TODO(),
+			createdObj,
+			metav1.UpdateOptions{},
+		)
+	assert.NoError(t, err)
+	assert.NotNil(t, updatedObj)
+
+	// give handler time to execute
+	assert.Eventually(t, func() bool { return assert.Equal(t, 1, updateObjectInvocations) }, 5*time.Second, time.Second)
+
+	// delete object --> no track anymore
+	assert.Equal(t, 0, deleteObjectInvocations)
+	err = k8sClient.dynamic().
+		Resource(gvr).Namespace(namespace).
+		Delete(
+			context.TODO(),
+			application,
+			metav1.DeleteOptions{},
+		)
+	assert.NoError(t, err)
+
+	// give handler time to execute
+	assert.Eventually(t, func() bool { return assert.Equal(t, 1, deleteObjectInvocations) }, 5*time.Second, time.Second)
+}
+
+func newUnstructuredDeployment(namespace, application, version, track string) *unstructured.Unstructured {
+	annotations := map[string]interface{}{
+		readyAnnotation: "true",
+	}
+	if track != "" {
+		annotations[trackAnnotation] = track
+	}
+
+	return &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"apiVersion": "apps/v1",
+			"kind":       "Deployment",
+			"metadata": map[string]interface{}{
+				"namespace": namespace,
+				"name":      application,
+				"labels": map[string]interface{}{
+					newLabel:     application,
+					versionLabel: version,
+					iter8Label:   "true",
+				},
+				"annotations": annotations,
+			},
+			"spec": application,
+		},
+	}
+}
diff --git a/autox/k8sclient_test.go b/autox/k8sclient_test.go
new file mode 100644
index 00000000..f8091fa0
--- /dev/null
+++ b/autox/k8sclient_test.go
@@ -0,0 +1,52 @@
+package autox
+
+import (
+	"helm.sh/helm/v3/pkg/cli"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	dynamicfake "k8s.io/client-go/dynamic/fake"
+	"k8s.io/client-go/kubernetes/fake"
+	ktesting "k8s.io/client-go/testing"
+)
+
+// newFakeKubeClient returns a fake Kubernetes client that is able to manage secrets
+// Includes dynamic client with Deployments as listed objects
+// Used by test cases in several packages to define (global) k8sclient.Client for testing
+func newFakeKubeClient(s *cli.EnvSettings, objects ...runtime.Object) *kubeClient {
+	fakeClient := kubeClient{
+		EnvSettings: s,
+		// default other fields
+	}
+
+	// secretDataReactor sets the secret.Data field based on the values from secret.StringData
+	// Credit: this function is adapted from https://github.com/creydr/go-k8s-utils
+	var secretDataReactor = func(action ktesting.Action) (bool, runtime.Object, error) {
+		secret, _ := action.(ktesting.CreateAction).GetObject().(*corev1.Secret)
+
+		if secret.Data == nil {
+			secret.Data = make(map[string][]byte)
+		}
+
+		for k, v := range secret.StringData {
+			secret.Data[k] = []byte(v)
+		}
+
+		return false, nil, nil
+	}
+
+	fc := fake.NewSimpleClientset(objects...)
+	fc.PrependReactor("create", "secrets", secretDataReactor)
+	fc.PrependReactor("update", "secrets", secretDataReactor)
+	fakeClient.clientset = fc
+
+	// fakeClient.dynamicClient = dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
+	fakeClient.dynamicClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
+		runtime.NewScheme(),
+		map[schema.GroupVersionResource]string{
+			{Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList",
+		},
+		objects...)
+
+	return &fakeClient
+}
diff --git a/autox/watcher_test.go b/autox/watcher_test.go
new file mode 100644
index 00000000..38bd9578
--- /dev/null
+++ b/autox/watcher_test.go
@@ -0,0 +1,58 @@
+package autox
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/iter8-tools/iter8/base/log"
+	"github.com/stretchr/testify/assert"
+	"helm.sh/helm/v3/pkg/cli"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+func TestAutoXStart(t *testing.T) {
+	addObjectInvocations := 0
+	addObject = func(obj interface{}) {
+		log.Logger.Debug("Add:", obj)
+		addObjectInvocations++
+	}
+
+	k8sClient = newFakeKubeClient(cli.New())
+
+	// Start requires some environment variables to be set
+	os.Setenv(RESOURCE_CONFIG_ENV, "../testdata/autox_inputs/resource_config.example.yaml")
+	os.Setenv(CHART_GROUP_CONFIG_ENV, "../testdata/autox_inputs/group_config.example.yaml")
+
+	stopCh := make(chan struct{})
+	defer close(stopCh)
+	Start(stopCh)
+
+	// create object; no track defined
+	assert.Equal(t, 0, addObjectInvocations)
+
+	gvr := schema.GroupVersionResource{
+		Group:    "apps",
+		Version:  "v1",
+		Resource: "deployments",
+	}
+	namespace := "default"
+	application := "demo"
+	version := "v1"
+	track := ""
+
+	createdObj, err := k8sClient.dynamic().
+		Resource(gvr).Namespace(namespace).
+		Create(
+			context.TODO(),
+			newUnstructuredDeployment(namespace, application, version, track),
+			metav1.CreateOptions{},
+		)
+	assert.NoError(t, err)
+	assert.NotNil(t, createdObj)
+
+	// give handler time to execute
+	assert.Eventually(t, func() bool { return assert.Equal(t, 1, addObjectInvocations) }, 5*time.Second, time.Second)
+}
diff --git a/testdata/autox_inputs/config.empty.yaml b/testdata/autox_inputs/config.empty.yaml
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/autox_inputs/config.garbage.yaml b/testdata/autox_inputs/config.garbage.yaml
new file mode 100644
index 00000000..8b0ce0c4
--- /dev/null
+++ b/testdata/autox_inputs/config.garbage.yaml
@@ -0,0 +1,1 @@
+invalid yaml file
\ No newline at end of file
diff --git a/testdata/autox_inputs/config.invalid.yaml b/testdata/autox_inputs/config.invalid.yaml
new file mode 100644
index 00000000..7daacd5d
--- /dev/null
+++ b/testdata/autox_inputs/config.invalid.yaml
@@ -0,0 +1,1 @@
+foo: bar
\ No newline at end of file
diff --git a/testdata/autox_inputs/group_config.example.yaml b/testdata/autox_inputs/group_config.example.yaml
new file mode 100644
index 00000000..72e409b0
--- /dev/null
+++ b/testdata/autox_inputs/group_config.example.yaml
@@ -0,0 +1,13 @@
+- name: myApp
+  charts:
+    - repo: abc
+      name: def
+      valuesFileUrl: abcdef.com/abcdef
+    - repo: ghi
+      name: jkl
+      valuesFileUrl: ghijkl.com/ghijkl
+- name: myApp2
+  charts:
+    - repo: mno
+      name: pqr
+      valuesFileUrl: mnopqr.com/mnopqr
\ No newline at end of file
diff --git a/testdata/autox_inputs/resource_config.example.yaml b/testdata/autox_inputs/resource_config.example.yaml
new file mode 100644
index 00000000..ec7709c4
--- /dev/null
+++ b/testdata/autox_inputs/resource_config.example.yaml
@@ -0,0 +1,6 @@
+namespaces:
+  - default
+resources:
+  - group: apps
+    version: v1
+    resource: deployments
\ No newline at end of file
diff --git a/testdata/autox_inputs/resource_config.nonamespaces.yaml b/testdata/autox_inputs/resource_config.nonamespaces.yaml
new file mode 100644
index 00000000..617ea212
--- /dev/null
+++ b/testdata/autox_inputs/resource_config.nonamespaces.yaml
@@ -0,0 +1,4 @@
+resources:
+  - group: apps
+    version: v1
+    resource: deployments