Repository: iter8-tools/iter8
Pull request #1313 (2022-07-29 14:35:45)

Iter8 A/B(/n) service

kalantar: I observe that the current implementation reads/writes to any secret with the appropriate name in the namespace of the application. This means that it might modify an existing secret used for another purpose. An alternative is needed (though the current approach can probably be merged for now). Propose using labels to identify secrets that are related to abn. Perhaps `iter8.tools/abn: <app name>`

Patch:
diff --git a/Makefile b/Makefile
index 6b0c8e4a..b2021faa 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 BINDIR      := $(CURDIR)/bin
 INSTALL_PATH ?= /usr/local/bin
 DIST_DIRS   := find * -type d -exec
-TARGETS     := darwin/amd64 linux/amd64 linux/386 windows/amd64
+TARGETS     ?= darwin/amd64 linux/amd64 linux/386 windows/amd64
 BINNAME     ?= iter8
 
 GOBIN         = $(shell go env GOBIN)
diff --git a/abn/application/application.go b/abn/application/application.go
new file mode 100644
index 00000000..3eaba3b4
--- /dev/null
+++ b/abn/application/application.go
@@ -0,0 +1,130 @@
+package application
+
+// application.go - type of application
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/iter8-tools/iter8/base/log"
+)
+
+// Application is an application observed in a kubernetes cluster
+type Application struct {
+	// Name is of the form namespace/Name where the Name is the value of the label app.kubernetes.io/Name
+	Name string `json:"name" yaml:"name"`
+	// Tracks is map from application track identifier to version name
+	Tracks Tracks `json:"tracks" yaml:"tracks"`
+	// Versions maps version name to version data (a set of metrics)
+	Versions `json:"versions" yaml:"versions"`
+}
+
+// Versions is a map of the version name to a version object
+type Versions map[string]*Version
+
+// Tracks is map of track identifiers to version names
+type Tracks map[string]string
+
+// NewApplication returns a new Application object with name
+func NewApplication(name string) *Application {
+	return &Application{
+		Name:     name,
+		Versions: Versions{},
+		Tracks:   Tracks{},
+	}
+}
+
+// ClearTracks clears the mapping of track identfiers to version names
+func (a *Application) ClearTracks() {
+	a.Tracks = Tracks{}
+}
+
+// GetVersion returns the Version object corresponding to a given version name
+// If no corresponding version object exists, a new one will be created when allowNew is set to true
+// returns the version object and a boolean indicating whether or not a new version was created or not
+func (a *Application) GetVersion(version string, allowNew bool) (*Version, bool) {
+	v, ok := a.Versions[version]
+	if !ok {
+		if allowNew {
+			log.Logger.Debugf("GetVersion no data found; returning %+v", v)
+			v = &Version{
+				Metrics: map[string]*SummaryMetric{},
+			}
+			a.Versions[version] = v
+			return v, true
+		}
+		return nil, false
+	}
+
+	log.Logger.Debugf("GetVersion returning %+v", v)
+	return v, false
+}
+
+// UnmarchalJSON unmarshals an application from a byte array. This is a
+// custom JSON unmarshaller to ensurer that maps are initialized
+func (a *Application) UnmarshalJSON(data []byte) error {
+	// use type alias to avoid infinite loop
+	type Alias Application
+	aux := &struct{ *Alias }{Alias: (*Alias)(a)}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	// initialize a.Versions if not already
+	if a.Versions == nil {
+		a.Versions = Versions{}
+	}
+	for _, v := range a.Versions {
+		if v.Metrics == nil {
+			v.Metrics = map[string]*SummaryMetric{}
+		}
+	}
+
+	return nil
+}
+
+func (a *Application) MarshalJSON() ([]byte, error) {
+	// use type Alias to avoid inifinite loop
+	type Alias Application
+	rawData, err := json.Marshal(&struct{ *Alias }{Alias: (*Alias)(a)})
+	if err != nil {
+		return rawData, err
+	}
+
+	// remove untracked versions if the rawData is too large
+	if len(rawData) > maxApplicationDataBytes {
+		deleteUntrackedVersions(a)
+		rawData, err = json.Marshal(&struct{ *Alias }{Alias: (*Alias)(a)})
+		if err != nil {
+			return rawData, err
+		}
+	}
+
+	// if it is still too large, return an error
+	if len(rawData) > maxApplicationDataBytes {
+		return rawData, errors.New("application data too large")
+	}
+
+	return rawData, nil
+}
+
+// String returns a string representation of the Application
+func (a *Application) String() string {
+	tracks := []string{}
+	for t, v := range a.Tracks {
+		tracks = append(tracks, t+" -> "+v)
+	}
+
+	versions := []string{}
+	for version := range a.Versions {
+		versions = append(versions, version)
+	}
+
+	return fmt.Sprintf("Application %s:\n\t%s\n\t%s", a.Name,
+		"tracks: ["+strings.Join(tracks, ",")+"]",
+		"versions: ["+strings.Join(versions, ",")+"]",
+	)
+}
diff --git a/abn/application/applicationmap.go b/abn/application/applicationmap.go
new file mode 100644
index 00000000..4c792caf
--- /dev/null
+++ b/abn/application/applicationmap.go
@@ -0,0 +1,301 @@
+package application
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/iter8-tools/iter8/base/log"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/yaml"
+)
+
+const (
+	// secretPostfix is the postfix added to an application name to create a secret name
+	secretPostfix string = ".iter8abnmetrics"
+	// secretKey is the name of the key in the data field of a kubernetes secret in which the application will be written
+	secretKey string = "application.yaml"
+	// defaultBatchWriteInterval is the default value of BatchWriteInterval
+	defaultBatchWriteInterval time.Duration = time.Duration(60 * time.Second)
+	// defaultMaxApplicationDataBytes is the default of maxApplicationDataBytes
+	// must be less than the maximum size of a Kubernetes secret (1 MB)
+	// The size of an application is proportional to the number of versions and the number of metrics per version.
+	// Since only summary metrics are permitted, each is a fixed size
+	defaultMaxApplicationDataBytes int = 750000
+)
+
+var (
+	// Applications is map of app name to Application
+	// This is a global variable used to maintain an internal representation of the applications in a cluster
+	Applications ThreadSafeApplicationMap
+	// BatchWriteInterval is the interval over which changes may batched before being persisted
+	BatchWriteInterval time.Duration
+	// maxApplicationDataBytes is the maximum number of bytes allowed in an applicaton (as YAML converted to []byte)
+	// this limit prevents trying to persist an application that is too large (Kubernetes secrets have a 1 MB size limit)
+	maxApplicationDataBytes int
+)
+
+// initalize global variables
+func init() {
+	Applications = ThreadSafeApplicationMap{
+		apps:           map[string]*Application{},
+		mutexes:        map[string]*sync.RWMutex{},
+		lastWriteTimes: map[string]*time.Time{},
+	}
+	BatchWriteInterval = defaultBatchWriteInterval
+	maxApplicationDataBytes = defaultMaxApplicationDataBytes // a secret's maximum size is 1MB
+}
+
+// ThreadSafeApplicationMap is type to control thread safety of operations on an application map
+type ThreadSafeApplicationMap struct {
+	// mutex is used to mediate read/write of the application map (ie, Get vs Add/Clear)
+	mutex sync.RWMutex
+	apps  map[string]*Application
+	// mutexes mediate read/write of individual applications within the map
+	mutexes        map[string]*sync.RWMutex
+	lastWriteTimes map[string]*time.Time
+}
+
+// RLock lock application for reading
+func (m *ThreadSafeApplicationMap) RLock(application string) {
+	m.mutexes[application].RLock()
+}
+
+// RUnlock undoes a single RLock call
+func (m *ThreadSafeApplicationMap) RUnlock(application string) {
+	m.mutexes[application].RUnlock()
+}
+
+// Lock lock application for writing
+func (m *ThreadSafeApplicationMap) Lock(application string) {
+	m.mutexes[application].Lock()
+}
+
+// Unlock unlocks application
+func (m *ThreadSafeApplicationMap) Unlock(application string) {
+	m.mutexes[application].Unlock()
+}
+
+// Put adds an application into the application map if it is not already there
+// Returns the application that is/was there
+func (m *ThreadSafeApplicationMap) Put(a *Application) *Application {
+	m.mutex.Lock()
+	defer m.mutex.Unlock()
+
+	current, ok := m.apps[a.Name]
+	if ok {
+		return current
+	}
+	m.apps[a.Name] = a
+	m.mutexes[a.Name] = &sync.RWMutex{}
+	return a
+}
+
+// Get application object from in memory map
+func (m *ThreadSafeApplicationMap) Get(application string) (*Application, error) {
+	// if available in the in-memory map, return it
+	m.mutex.RLock()
+	a, ok := m.apps[application]
+	m.mutex.RUnlock()
+	if ok {
+		return a, nil
+	}
+
+	return nil, errors.New(application + " not in memory")
+}
+
+// Read the application object if necessary from persistent storage (a secret)
+func (m *ThreadSafeApplicationMap) Read(application string) (*Application, error) {
+	a, err := m.Get(application)
+	if a != nil {
+		return a, err
+	}
+
+	// otherwise, read from persistent store (secret)
+	// if no secret, create new object
+	a, err = m.readFromSecret(application)
+
+	// and add to the in memory map
+	a = m.Put(a)
+
+	return a, err
+}
+
+// readFromSecret reads the application from persistent storage (a Kubernetes secret)
+// - the secret name/namespace is the same as the application
+// - if no application is present in the persistent storage, a new object is created
+func (m *ThreadSafeApplicationMap) readFromSecret(application string) (*Application, error) {
+	secretNamespace := namespaceFromKey(application)
+	secretName := nameFromKey(application) + secretPostfix
+
+	newApplication := NewApplication(application)
+
+	// read secret from cluster; extract appData
+	secret, err := k8sclient.Client.Typed().CoreV1().Secrets(secretNamespace).Get(context.Background(), secretName, metav1.GetOptions{})
+	if err != nil {
+		log.Logger.Debug("no secret backing " + application)
+		return newApplication, err
+	}
+
+	// read data from secret (is a yaml file)
+	rawData, ok := secret.Data[secretKey]
+	if !ok {
+		log.Logger.Debug("key missing in backing secret")
+		return newApplication, errors.New("secret does not contain expected key: " + secretKey)
+	}
+
+	a := &Application{}
+	err = yaml.Unmarshal(rawData, a)
+	if err != nil {
+		log.Logger.Debug("unmarshal failure")
+		return newApplication, nil
+	}
+
+	// set name
+	a.Name = application
+
+	// set last write time to read time; it was written in the past
+	now := time.Now()
+	m.lastWriteTimes[a.Name] = &now
+
+	return a, nil
+}
+
+// Write writes the Application to persistent storage (a Kubernetes secret)
+func (m *ThreadSafeApplicationMap) Write(a *Application) error {
+	log.Logger.Tracef("Write called with %#v", a)
+	defer log.Logger.Trace("Write completed")
+	var secret *corev1.Secret
+
+	// marshal to byte array
+	// note that this uses a custom MarshalJSON that removes untracked
+	// versions if the application data is too large
+	rawData, err := yaml.Marshal(a)
+	if err != nil {
+		return err
+	}
+
+	secretNamespace := namespaceFromKey(a.Name)
+	secretName := secretNameFromKey(a.Name)
+
+	// determine if need to
+	exists := true
+	secret, err = k8sclient.Client.Typed().CoreV1().Secrets(secretNamespace).Get(context.Background(), secretName, metav1.GetOptions{})
+	if err != nil {
+		exists = false
+		secret = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      secretName,
+				Namespace: secretNamespace,
+			},
+			Data:       map[string][]byte{},
+			StringData: map[string]string{},
+		}
+		log.Logger.Debug("secret does not exist; creating")
+	}
+
+	secret.Data[secretKey] = rawData
+	if secret.StringData != nil {
+		secret.StringData[secretKey] = string(rawData)
+	}
+
+	// create or update the secret
+	if exists {
+		// TBD do we need to merge what we have?
+		_, err = k8sclient.Client.Typed().CoreV1().Secrets(secretNamespace).Update(
+			context.Background(),
+			secret,
+			metav1.UpdateOptions{},
+		)
+	} else {
+		_, err = k8sclient.Client.Typed().CoreV1().Secrets(secretNamespace).Create(
+			context.Background(),
+			secret,
+			metav1.CreateOptions{},
+		)
+	}
+	if err != nil {
+		log.Logger.WithError(err).Warn("unable to persist application")
+		return err
+	}
+
+	// update last write time for application
+	now := time.Now()
+	m.lastWriteTimes[a.Name] = &now
+	return nil
+}
+
+// BatchedWrite writes the Application to persistent storage only if the previous write
+// was more than BatchWriteInterval ago.
+func (m *ThreadSafeApplicationMap) BatchedWrite(a *Application) error {
+	log.Logger.Tracef("BatchedWrite called")
+	defer log.Logger.Trace("BatchedWrite completed")
+
+	now := time.Now()
+	lastWrite, ok := m.lastWriteTimes[a.Name]
+	if !ok || lastWrite == nil {
+		// no record of the application ever being written; write it now
+		m.Write(a)
+	} else {
+		if now.Sub(*m.lastWriteTimes[a.Name]) > BatchWriteInterval {
+			m.Write(a)
+		}
+	}
+
+	// it was written too recently; wait until another write call
+	return nil
+}
+
+func deleteUntrackedVersions(a *Application) {
+	toDelete := []string{}
+	for version := range a.Versions {
+		track := ""
+		for _, ver := range a.Tracks {
+			if ver == version {
+				track = ver
+				break
+			}
+		}
+		if track == "" {
+			toDelete = append(toDelete, version)
+		}
+	}
+
+	for _, version := range toDelete {
+		delete(a.Versions, version)
+	}
+}
+
+// nameFromKey returns the name from a key of the form "namespace/name"
+func nameFromKey(applicationKey string) string {
+	_, n := splitApplicationKey(applicationKey)
+	return n
+}
+
+// secretNameFromKey returns the name of the secret used to persist an applicatiob
+func secretNameFromKey(applicationKey string) string {
+	return nameFromKey(applicationKey) + secretPostfix
+}
+
+// namespaceFromKey returns the namespace from a key of the form "namespace/name"
+func namespaceFromKey(applicationKey string) string {
+	ns, _ := splitApplicationKey(applicationKey)
+	return ns
+}
+
+// splitApplicationKey is a utility function that returns the name and namespace from a key of the form "namespace/name"
+func splitApplicationKey(applicationKey string) (string, string) {
+	var name, namespace string
+	names := strings.Split(applicationKey, "/")
+	if len(names) > 1 {
+		namespace, name = names[0], names[1]
+	} else {
+		namespace, name = "default", names[0]
+	}
+
+	return namespace, name
+}
diff --git a/abn/application/doc.go b/abn/application/doc.go
new file mode 100644
index 00000000..3ce32ad9
--- /dev/null
+++ b/abn/application/doc.go
@@ -0,0 +1,2 @@
+// Package application provides a runtime model of applications and their versions
+package application
diff --git a/abn/application/summary_metric.go b/abn/application/summary_metric.go
new file mode 100644
index 00000000..1e50df74
--- /dev/null
+++ b/abn/application/summary_metric.go
@@ -0,0 +1,106 @@
+package application
+
+// summary_metric.go - defines a summary metric object. For space efficiency it is an array of 6 float64.
+
+import (
+	"fmt"
+	"math"
+)
+
+// SummaryMetric is summary metric type
+type SummaryMetric [5]float64
+
+const (
+	countIdx = 0
+	sumIdx   = 1
+	minIdx   = 2
+	maxIdx   = 3
+	ssIdx    = 4
+)
+
+// EmptySummaryMetric returns metric object without any values added
+func EmptySummaryMetric() *SummaryMetric {
+	m := SummaryMetric{
+		0,                           // Count
+		0,                           // Sum
+		math.MaxFloat64,             // Min
+		math.SmallestNonzeroFloat64, // Max
+		0,                           // SumSquares
+	}
+	return &m
+}
+
+// Count returns the number of observed values summarized by the metric
+func (m *SummaryMetric) Count() uint32 {
+	return uint32(math.Round((*m)[countIdx]))
+}
+
+// SetCount sets the number of observed values summarized by the metric
+func (m *SummaryMetric) SetCount(v uint32) {
+	(*m)[countIdx] = float64(v)
+}
+
+// Sum is the sum of the observed values
+func (m *SummaryMetric) Sum() float64 {
+	return (*m)[sumIdx]
+}
+
+// SetSum sets the sum of the observed values
+func (m *SummaryMetric) SetSum(v float64) {
+	(*m)[sumIdx] = v
+}
+
+// Min is the minimum of the observed values
+func (m *SummaryMetric) Min() float64 {
+	return (*m)[minIdx]
+}
+
+// SetMin sets the minimum of the observed values
+func (m *SummaryMetric) SetMin(v float64) {
+	if v < m.Min() {
+		(*m)[minIdx] = v
+	}
+}
+
+// Max is the maximum of the observed values
+func (m *SummaryMetric) Max() float64 {
+	return (*m)[maxIdx]
+}
+
+// SetMax sets the maximum of the observed values
+func (m *SummaryMetric) SetMax(v float64) {
+	if v > m.Max() {
+		(*m)[maxIdx] = v
+	}
+}
+
+// SumSquares is the sum of the squares of the observed values
+func (m *SummaryMetric) SumSquares() float64 {
+	return (*m)[ssIdx]
+}
+
+// SetSumSquares sets the sum of the squares of the observed values
+func (m *SummaryMetric) SetSumSquares(v float64) {
+	(*m)[ssIdx] = v
+}
+
+// Add adds an observed value to the metric
+func (m *SummaryMetric) Add(value float64) *SummaryMetric {
+	m.SetCount(m.Count() + 1)
+	m.SetSum(m.Sum() + value)
+	m.SetMin(value)
+	m.SetMax(value)
+	m.SetSumSquares(m.SumSquares() + (value * value))
+	return m
+}
+
+// String returns a string representing the metric (not all fields are included)
+func (m *SummaryMetric) String() string {
+	return fmt.Sprintf("[%d] %f, %f, %f, %f",
+		m.Count(),
+		m.Min(),
+		m.Max(),
+		m.Sum(),
+		m.SumSquares(),
+	)
+}
diff --git a/abn/application/version.go b/abn/application/version.go
new file mode 100644
index 00000000..0e1b048d
--- /dev/null
+++ b/abn/application/version.go
@@ -0,0 +1,41 @@
+package application
+
+// version.go - supports notion of version of an application
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Version is information about versions of an application in a Kubernetes cluster
+type Version struct {
+	// List of (summary) metrics for a version
+	Metrics map[string]*SummaryMetric `json:"metrics" yaml:"metrics"`
+}
+
+// GetMetric returns a metric from the list of metrics associated with a version
+// If no metric is present for a given name, a new one is created
+func (v *Version) GetMetric(metric string, allowNew bool) (*SummaryMetric, bool) {
+	m, ok := v.Metrics[metric]
+	if !ok {
+		if allowNew {
+			m := EmptySummaryMetric()
+			v.Metrics[metric] = m
+			return m, true
+		} else {
+			return nil, false
+		}
+	}
+	return m, false
+}
+
+func (v *Version) String() string {
+	metrics := []string{}
+	for n, m := range v.Metrics {
+		metrics = append(metrics, fmt.Sprintf("%s(%d)", n, m.Count()))
+	}
+
+	return fmt.Sprintf("\n%s",
+		"- metrics: ["+strings.Join(metrics, ",")+"]",
+	)
+}
diff --git a/abn/core/config.go b/abn/core/config.go
new file mode 100644
index 00000000..065d2052
--- /dev/null
+++ b/abn/core/config.go
@@ -0,0 +1,52 @@
+package core
+
+// config.go - reading of configuration (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"
+)
+
+// config is A/B(/n) service configuration. The configuration identifies the resources to watch and in which namespaces.
+type config 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"`
+}
+
+// readConfig reads yaml config file fn and converts to a Config object
+func readConfig(fn string) (c config) {
+	// empty configuration
+	c = config{
+		Namespaces: []string{},
+		Resources:  []schema.GroupVersionResource{},
+	}
+
+	yfile, err := ioutil.ReadFile(fn)
+	if err != nil {
+		log.Logger.Warnf("unable to read configuration file %s: %s", fn, err.Error())
+		return c // empty configuration
+	}
+
+	log.Logger.Debugf("read configuration\n%s", string(yfile))
+
+	err = yaml.Unmarshal(yfile, &c)
+	if err != nil {
+		log.Logger.Warnf("invalid configuration file %s: %s", fn, err.Error())
+		return c // empty configuration
+	}
+
+	if len(c.Namespaces) == 0 {
+		log.Logger.Warn("not watching any namespaces - configuration error?")
+	}
+	if len(c.Resources) == 0 {
+		log.Logger.Warn("not watching any resources - configuration error?")
+	}
+
+	return c
+}
diff --git a/abn/core/doc.go b/abn/core/doc.go
new file mode 100644
index 00000000..cabf9cbe
--- /dev/null
+++ b/abn/core/doc.go
@@ -0,0 +1,2 @@
+// Package core provides a service that supports the Iter8 A/B(/n) SDK
+package core
diff --git a/abn/core/get_application_data.go b/abn/core/get_application_data.go
new file mode 100644
index 00000000..7732104e
--- /dev/null
+++ b/abn/core/get_application_data.go
@@ -0,0 +1,22 @@
+package core
+
+import (
+	"encoding/json"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+)
+
+// getApplicationDataInternal is detailed implementation of gRPC method GetApplicationData
+func getApplicationDataInternal(application string) (string, error) {
+	a, err := abnapp.Applications.Get(application)
+	if err != nil {
+		return "", err
+	}
+
+	jsonBytes, err := json.Marshal(a)
+	if err != nil {
+		return "", err
+	}
+
+	return string(jsonBytes), nil
+}
diff --git a/abn/core/lookup.go b/abn/core/lookup.go
new file mode 100644
index 00000000..44a3c648
--- /dev/null
+++ b/abn/core/lookup.go
@@ -0,0 +1,73 @@
+package core
+
+// lookup.go -(internal) implementation of gRPC Lookup method
+
+import (
+	"errors"
+	"fmt"
+	"hash/maphash"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+)
+
+// lookupInternal is detailed implementation of gRPC method Lookup
+func lookupInternal(application string, user string) (*abnapp.Application, *string, error) {
+	// if user is not provided, fail
+	if user == "" {
+		return nil, nil, errors.New("no user session provided")
+	}
+
+	// check that we have a record of the application
+	a, err := abnapp.Applications.Get(application)
+	if err != nil {
+		return nil, nil, fmt.Errorf("application not found: %s", err.Error())
+	}
+	if a == nil {
+		return nil, nil, errors.New("application not found")
+	}
+
+	// use rendezvous hash to get track for user, fail if not present
+	abnapp.Applications.RLock(application)
+	defer abnapp.Applications.RUnlock(application)
+	track := rendezvousGet(a, user)
+	return a, &track, nil
+}
+
+// rendezvousGet is an implementation of rendezvous hashing (cf. https://en.wikipedia.org/wiki/Rendezvous_hashing)
+// It returns a consistent track for a given application and user combination.
+// The track is chosen uniformly at random from among the current set of tracks
+// associated with an application.
+// We want to always return the same track for the same user so long as the
+// application remains unchanged -- there are no change in the set of versions
+// and no change to the track mapping.
+// To do this, we hash the combination of user and version. We don't use the track identifier
+// because the track identifier is associated with multiple versions over time; we do not
+// require a fixed mapping when this mapping changes.
+// We select the version, user pair with the largest hash value ("score").
+// Inspired by https://github.com/tysonmote/rendezvous/blob/master/rendezvous.go
+func rendezvousGet(a *abnapp.Application, user string) string {
+	// current maximimum score as computed by the hash function
+	var maxScore uint64
+	// maxTrack is the track with the current maximum score
+	var maxTrack string
+	// maxVersion is the version name associated with maxTrack
+	var maxVersion string
+
+	for track, version := range a.Tracks {
+		score := hash(version, user)
+		if score > maxScore || (score == maxScore && version > maxVersion) {
+			maxScore = score
+			maxVersion = version
+			maxTrack = track
+		}
+	}
+	return maxTrack
+}
+
+// hash computes the score for a version, user combination
+func hash(version, user string) uint64 {
+	var hasher maphash.Hash
+	hasher.WriteString(user)
+	hasher.WriteString(version)
+	return hasher.Sum64()
+}
diff --git a/abn/core/service.go b/abn/core/service.go
new file mode 100644
index 00000000..8ecee22d
--- /dev/null
+++ b/abn/core/service.go
@@ -0,0 +1,123 @@
+package core
+
+// service.go - entry point for A/B(/n) service; starts controller watching resources
+//               and gRPC service to respond to lookup and write metric requests
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"net"
+	"os"
+	"os/signal"
+	"syscall"
+
+	pb "github.com/iter8-tools/iter8/abn/grpc"
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/iter8-tools/iter8/abn/watcher"
+	"github.com/iter8-tools/iter8/base/log"
+
+	"google.golang.org/grpc"
+	"google.golang.org/protobuf/types/known/emptypb"
+
+	"helm.sh/helm/v3/pkg/cli"
+	_ "k8s.io/client-go/plugin/pkg/client/auth"
+)
+
+const (
+	// Name of environment variable with file path to configuration yaml file
+	watcherConfigEnv = "WATCHER_CONFIG"
+)
+
+var (
+	// port the service listens on
+	port = flag.Int("port", 50051, "The server port")
+)
+
+// Start is entry point to configure services and start them
+func Start() {
+	k8sclient.Client = *k8sclient.NewKubeClient(cli.New())
+	k8sclient.Client.Initialize()
+
+	// read abn config (resources and namespaces to watch)
+	abnConfigFile, ok := os.LookupEnv(watcherConfigEnv)
+	if !ok {
+		log.Logger.Fatal("configuation file is required")
+	}
+
+	stopCh := make(chan struct{})
+
+	// set up resource watching as defined by config
+	c := readConfig(abnConfigFile)
+	w := watcher.NewIter8Watcher(c.Resources, c.Namespaces)
+	go w.Start(stopCh)
+
+	// launch gRPC server to respond to frontend requests
+	go launchGRPCServer([]grpc.ServerOption{})
+
+	sigCh := make(chan os.Signal, 1)
+	signal.Notify(sigCh, syscall.SIGTERM, os.Interrupt)
+
+	<-sigCh
+	close(stopCh)
+}
+
+// newServer returns a new gRPC server
+func newServer() *abnServer {
+	return &abnServer{}
+}
+
+type abnServer struct {
+	pb.UnimplementedABNServer
+}
+
+// Lookup identifies a track that should be used for a given user
+// This method is exposed to gRPC clients
+func (server *abnServer) Lookup(ctx context.Context, appMsg *pb.Application) (*pb.Session, error) {
+	_, track, err := lookupInternal(
+		appMsg.GetName(),
+		appMsg.GetUser(),
+	)
+
+	if err != nil || track == nil {
+		return nil, err
+	}
+	return &pb.Session{
+		Track: *track,
+	}, err
+}
+
+// WriteMetric identifies the track with which a metric is associated (from user) and
+// writes the metric value (currently only supports summary metrics)
+func (server *abnServer) WriteMetric(ctx context.Context, metricMsg *pb.MetricValue) (*emptypb.Empty, error) {
+	err := writeMetricInternal(
+		metricMsg.GetApplication(),
+		metricMsg.GetUser(),
+		metricMsg.GetName(),
+		metricMsg.GetValue(),
+	)
+
+	return &emptypb.Empty{}, err
+}
+
+func (server *abnServer) GetApplicationData(ctx context.Context, metricReqMsg *pb.ApplicationRequest) (*pb.ApplicationData, error) {
+	jsonStr, err := getApplicationDataInternal(
+		metricReqMsg.GetApplication(),
+	)
+
+	return &pb.ApplicationData{
+		ApplicationJson: jsonStr,
+	}, err
+}
+
+// launchGRPCServer starts gRPC server
+func launchGRPCServer(opts []grpc.ServerOption) {
+	lis, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", *port))
+	if err != nil {
+		log.Logger.WithError(err).Fatal("failed to listen")
+	}
+
+	grpcServer := grpc.NewServer(opts...)
+	pb.RegisterABNServer(grpcServer, newServer())
+	grpcServer.Serve(lis)
+}
diff --git a/abn/core/write_metric.go b/abn/core/write_metric.go
new file mode 100644
index 00000000..89dce201
--- /dev/null
+++ b/abn/core/write_metric.go
@@ -0,0 +1,45 @@
+package core
+
+import (
+	"errors"
+	"strconv"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	"github.com/iter8-tools/iter8/base/log"
+)
+
+// writeMetricInternal is detailed implementation of gRPC method WriteMetric
+func writeMetricInternal(application, user, metric, valueStr string) error {
+	a, track, err := lookupInternal(application, user)
+	if err != nil || track == nil {
+		return err
+	}
+
+	// lock for write; we will modify the metric
+	abnapp.Applications.Lock(application)
+	defer abnapp.Applications.Unlock(application)
+
+	version, ok := a.Tracks[*track]
+	if !ok {
+		return errors.New("track not mapped to version")
+	}
+
+	v, _ := a.GetVersion(version, false)
+	if v == nil {
+		return errors.New("unexpected: trying to write metrics for unknown version")
+	}
+
+	value, err := strconv.ParseFloat(valueStr, 64)
+	if err != nil {
+		log.Logger.Warn("Unable to parse metric value ", valueStr)
+		return nil
+	}
+
+	m, _ := v.GetMetric(metric, true)
+	m.Add(value)
+
+	// persist updated metric
+	abnapp.Applications.BatchedWrite(a)
+
+	return err
+}
diff --git a/abn/grpc/abn.pb.go b/abn/grpc/abn.pb.go
new file mode 100644
index 00000000..758f5628
--- /dev/null
+++ b/abn/grpc/abn.pb.go
@@ -0,0 +1,469 @@
+// protoc --go_out=. --go_opt=paths=source_relative     --go-grpc_out=. --go-grpc_opt=paths=source_relative     abn/grpc/abn.proto
+// python -m grpc_tools.protoc -I../../../iter8-tools/iter8/abn/grpc --python_out=. --grpc_python_out=. ../../../iter8-tools/iter8/abn/grpc/abn.proto
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.26.0
+// 	protoc        v3.19.4
+// source: abn/grpc/abn.proto
+
+package grpc
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type Application struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// name of (backend) application or service
+	// This value is used to identify the Kubernetes objects that make up the service
+	// Kubernetes objects that comprise the service should have the label app.kubernetes.io/name set to name
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// User or user session identifier
+	User string `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"`
+}
+
+func (x *Application) Reset() {
+	*x = Application{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_abn_grpc_abn_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Application) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Application) ProtoMessage() {}
+
+func (x *Application) ProtoReflect() protoreflect.Message {
+	mi := &file_abn_grpc_abn_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Application.ProtoReflect.Descriptor instead.
+func (*Application) Descriptor() ([]byte, []int) {
+	return file_abn_grpc_abn_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Application) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Application) GetUser() string {
+	if x != nil {
+		return x.User
+	}
+	return ""
+}
+
+type Session struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Track identifier of an application version
+	// If this is not available, it will be the empty string
+	Track string `protobuf:"bytes,1,opt,name=track,proto3" json:"track,omitempty"`
+}
+
+func (x *Session) Reset() {
+	*x = Session{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_abn_grpc_abn_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Session) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Session) ProtoMessage() {}
+
+func (x *Session) ProtoReflect() protoreflect.Message {
+	mi := &file_abn_grpc_abn_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Session.ProtoReflect.Descriptor instead.
+func (*Session) Descriptor() ([]byte, []int) {
+	return file_abn_grpc_abn_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Session) GetTrack() string {
+	if x != nil {
+		return x.Track
+	}
+	return ""
+}
+
+type MetricValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Metric name
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Metric value
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+	// name of application
+	Application string `protobuf:"bytes,3,opt,name=application,proto3" json:"application,omitempty"`
+	// User or user session identifier
+	User string `protobuf:"bytes,4,opt,name=user,proto3" json:"user,omitempty"`
+}
+
+func (x *MetricValue) Reset() {
+	*x = MetricValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_abn_grpc_abn_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MetricValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MetricValue) ProtoMessage() {}
+
+func (x *MetricValue) ProtoReflect() protoreflect.Message {
+	mi := &file_abn_grpc_abn_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MetricValue.ProtoReflect.Descriptor instead.
+func (*MetricValue) Descriptor() ([]byte, []int) {
+	return file_abn_grpc_abn_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *MetricValue) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *MetricValue) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+func (x *MetricValue) GetApplication() string {
+	if x != nil {
+		return x.Application
+	}
+	return ""
+}
+
+func (x *MetricValue) GetUser() string {
+	if x != nil {
+		return x.User
+	}
+	return ""
+}
+
+type ApplicationRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// name of application
+	Application string `protobuf:"bytes,1,opt,name=application,proto3" json:"application,omitempty"`
+}
+
+func (x *ApplicationRequest) Reset() {
+	*x = ApplicationRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_abn_grpc_abn_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ApplicationRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ApplicationRequest) ProtoMessage() {}
+
+func (x *ApplicationRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_abn_grpc_abn_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ApplicationRequest.ProtoReflect.Descriptor instead.
+func (*ApplicationRequest) Descriptor() ([]byte, []int) {
+	return file_abn_grpc_abn_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ApplicationRequest) GetApplication() string {
+	if x != nil {
+		return x.Application
+	}
+	return ""
+}
+
+type ApplicationData struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// JSON string of application
+	ApplicationJson string `protobuf:"bytes,1,opt,name=applicationJson,proto3" json:"applicationJson,omitempty"`
+}
+
+func (x *ApplicationData) Reset() {
+	*x = ApplicationData{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_abn_grpc_abn_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ApplicationData) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ApplicationData) ProtoMessage() {}
+
+func (x *ApplicationData) ProtoReflect() protoreflect.Message {
+	mi := &file_abn_grpc_abn_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ApplicationData.ProtoReflect.Descriptor instead.
+func (*ApplicationData) Descriptor() ([]byte, []int) {
+	return file_abn_grpc_abn_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ApplicationData) GetApplicationJson() string {
+	if x != nil {
+		return x.ApplicationJson
+	}
+	return ""
+}
+
+var File_abn_grpc_abn_proto protoreflect.FileDescriptor
+
+var file_abn_grpc_abn_proto_rawDesc = []byte{
+	0x0a, 0x12, 0x61, 0x62, 0x6e, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x61, 0x62, 0x6e, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74,
+	0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x35, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x69,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73,
+	0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x1f,
+	0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x72, 0x61,
+	0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x22,
+	0x6d, 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12,
+	0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61,
+	0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x70, 0x70, 0x6c,
+	0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61,
+	0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73,
+	0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x36,
+	0x0a, 0x12, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x70, 0x70, 0x6c, 0x69,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3b, 0x0a, 0x0f, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, 0x61, 0x12, 0x28, 0x0a, 0x0f, 0x61, 0x70, 0x70,
+	0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4a, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0f, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4a,
+	0x73, 0x6f, 0x6e, 0x32, 0xb8, 0x01, 0x0a, 0x03, 0x41, 0x42, 0x4e, 0x12, 0x2c, 0x0a, 0x06, 0x4c,
+	0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x41, 0x70, 0x70,
+	0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x0d, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e,
+	0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x3a, 0x0a, 0x0b, 0x57, 0x72, 0x69,
+	0x74, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e,
+	0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x16, 0x2e, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
+	0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x41, 0x70, 0x70, 0x6c,
+	0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, 0x61, 0x12, 0x18, 0x2e, 0x6d, 0x61,
+	0x69, 0x6e, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x41, 0x70, 0x70,
+	0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, 0x61, 0x22, 0x00, 0x42, 0x27,
+	0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x69, 0x74, 0x65,
+	0x72, 0x38, 0x2d, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x2f, 0x69, 0x74, 0x65, 0x72, 0x38, 0x2f, 0x61,
+	0x62, 0x6e, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_abn_grpc_abn_proto_rawDescOnce sync.Once
+	file_abn_grpc_abn_proto_rawDescData = file_abn_grpc_abn_proto_rawDesc
+)
+
+func file_abn_grpc_abn_proto_rawDescGZIP() []byte {
+	file_abn_grpc_abn_proto_rawDescOnce.Do(func() {
+		file_abn_grpc_abn_proto_rawDescData = protoimpl.X.CompressGZIP(file_abn_grpc_abn_proto_rawDescData)
+	})
+	return file_abn_grpc_abn_proto_rawDescData
+}
+
+var file_abn_grpc_abn_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
+var file_abn_grpc_abn_proto_goTypes = []interface{}{
+	(*Application)(nil),        // 0: main.Application
+	(*Session)(nil),            // 1: main.Session
+	(*MetricValue)(nil),        // 2: main.MetricValue
+	(*ApplicationRequest)(nil), // 3: main.ApplicationRequest
+	(*ApplicationData)(nil),    // 4: main.ApplicationData
+	(*emptypb.Empty)(nil),      // 5: google.protobuf.Empty
+}
+var file_abn_grpc_abn_proto_depIdxs = []int32{
+	0, // 0: main.ABN.Lookup:input_type -> main.Application
+	2, // 1: main.ABN.WriteMetric:input_type -> main.MetricValue
+	3, // 2: main.ABN.GetApplicationData:input_type -> main.ApplicationRequest
+	1, // 3: main.ABN.Lookup:output_type -> main.Session
+	5, // 4: main.ABN.WriteMetric:output_type -> google.protobuf.Empty
+	4, // 5: main.ABN.GetApplicationData:output_type -> main.ApplicationData
+	3, // [3:6] is the sub-list for method output_type
+	0, // [0:3] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_abn_grpc_abn_proto_init() }
+func file_abn_grpc_abn_proto_init() {
+	if File_abn_grpc_abn_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_abn_grpc_abn_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Application); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_abn_grpc_abn_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Session); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_abn_grpc_abn_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MetricValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_abn_grpc_abn_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ApplicationRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_abn_grpc_abn_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ApplicationData); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_abn_grpc_abn_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   5,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_abn_grpc_abn_proto_goTypes,
+		DependencyIndexes: file_abn_grpc_abn_proto_depIdxs,
+		MessageInfos:      file_abn_grpc_abn_proto_msgTypes,
+	}.Build()
+	File_abn_grpc_abn_proto = out.File
+	file_abn_grpc_abn_proto_rawDesc = nil
+	file_abn_grpc_abn_proto_goTypes = nil
+	file_abn_grpc_abn_proto_depIdxs = nil
+}
diff --git a/abn/grpc/abn.proto b/abn/grpc/abn.proto
new file mode 100644
index 00000000..4d8c3b70
--- /dev/null
+++ b/abn/grpc/abn.proto
@@ -0,0 +1,63 @@
+// protoc --go_out=. --go_opt=paths=source_relative     --go-grpc_out=. --go-grpc_opt=paths=source_relative     abn/grpc/abn.proto
+// python -m grpc_tools.protoc -I../../../iter8-tools/iter8/abn/grpc --python_out=. --grpc_python_out=. ../../../iter8-tools/iter8/abn/grpc/abn.proto 
+
+syntax = "proto3";
+
+option go_package = "github.com/iter8-tools/iter8/abn/grpc";
+
+import "google/protobuf/empty.proto";
+package main;
+
+// for more information, see https://github.com/iter8-tools/iter8/issues/1257
+
+service ABN {
+  // Identify a track the caller should send a request to.
+  // Should be called for each request (transaction).
+  rpc Lookup(Application) returns(Session) {}
+
+  // Write a metric value to metrics database.
+  // The metric value is explicitly associated with a list of transactions that contributed to its computation.
+  // The user is expected to identify these transactions.
+  rpc WriteMetric(MetricValue) returns (google.protobuf.Empty) {}
+
+  // Get application data (tracks, versions and metrics for each)
+  rpc GetApplicationData(ApplicationRequest) returns (ApplicationData) {}
+}
+
+message Application {
+  // name of (backend) application or service
+  // This value is used to identify the Kubernetes objects that make up the service
+  // Kubernetes objects that comprise the service should have the label app.kubernetes.io/name set to name
+  string name = 1;
+  // User or user session identifier
+  string user = 2;
+}
+
+message Session {
+  // Track identifier of an application version
+  // If this is not available, it will be the empty string
+  string track = 1;
+}
+
+message MetricValue {
+  // Metric name
+  string name = 1;
+  // Metric value
+  string value = 2;
+  // name of application
+  string application = 3;
+  // User or user session identifier
+  string user = 4;
+}
+
+message ApplicationRequest {
+  // name of application
+  string application = 1;
+}
+
+message ApplicationData {
+  // JSON string of application
+  string applicationJson = 1;
+}
+
+// https://developers.google.com/protocol-buffers/docs/proto3
\ No newline at end of file
diff --git a/abn/grpc/abn_grpc.pb.go b/abn/grpc/abn_grpc.pb.go
new file mode 100644
index 00000000..643789af
--- /dev/null
+++ b/abn/grpc/abn_grpc.pb.go
@@ -0,0 +1,186 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+
+package grpc
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+// ABNClient is the client API for ABN service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type ABNClient interface {
+	// Identify a track the caller should send a request to.
+	// Should be called for each request (transaction).
+	Lookup(ctx context.Context, in *Application, opts ...grpc.CallOption) (*Session, error)
+	// Write a metric value to metrics database.
+	// The metric value is explicitly associated with a list of transactions that contributed to its computation.
+	// The user is expected to identify these transactions.
+	WriteMetric(ctx context.Context, in *MetricValue, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// Get application data (tracks, versions and metrics for each)
+	GetApplicationData(ctx context.Context, in *ApplicationRequest, opts ...grpc.CallOption) (*ApplicationData, error)
+}
+
+type aBNClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewABNClient(cc grpc.ClientConnInterface) ABNClient {
+	return &aBNClient{cc}
+}
+
+func (c *aBNClient) Lookup(ctx context.Context, in *Application, opts ...grpc.CallOption) (*Session, error) {
+	out := new(Session)
+	err := c.cc.Invoke(ctx, "/main.ABN/Lookup", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *aBNClient) WriteMetric(ctx context.Context, in *MetricValue, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/main.ABN/WriteMetric", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *aBNClient) GetApplicationData(ctx context.Context, in *ApplicationRequest, opts ...grpc.CallOption) (*ApplicationData, error) {
+	out := new(ApplicationData)
+	err := c.cc.Invoke(ctx, "/main.ABN/GetApplicationData", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ABNServer is the server API for ABN service.
+// All implementations must embed UnimplementedABNServer
+// for forward compatibility
+type ABNServer interface {
+	// Identify a track the caller should send a request to.
+	// Should be called for each request (transaction).
+	Lookup(context.Context, *Application) (*Session, error)
+	// Write a metric value to metrics database.
+	// The metric value is explicitly associated with a list of transactions that contributed to its computation.
+	// The user is expected to identify these transactions.
+	WriteMetric(context.Context, *MetricValue) (*emptypb.Empty, error)
+	// Get application data (tracks, versions and metrics for each)
+	GetApplicationData(context.Context, *ApplicationRequest) (*ApplicationData, error)
+	mustEmbedUnimplementedABNServer()
+}
+
+// UnimplementedABNServer must be embedded to have forward compatible implementations.
+type UnimplementedABNServer struct {
+}
+
+func (UnimplementedABNServer) Lookup(context.Context, *Application) (*Session, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method Lookup not implemented")
+}
+func (UnimplementedABNServer) WriteMetric(context.Context, *MetricValue) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method WriteMetric not implemented")
+}
+func (UnimplementedABNServer) GetApplicationData(context.Context, *ApplicationRequest) (*ApplicationData, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetApplicationData not implemented")
+}
+func (UnimplementedABNServer) mustEmbedUnimplementedABNServer() {}
+
+// UnsafeABNServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to ABNServer will
+// result in compilation errors.
+type UnsafeABNServer interface {
+	mustEmbedUnimplementedABNServer()
+}
+
+func RegisterABNServer(s grpc.ServiceRegistrar, srv ABNServer) {
+	s.RegisterService(&ABN_ServiceDesc, srv)
+}
+
+func _ABN_Lookup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Application)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ABNServer).Lookup(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/main.ABN/Lookup",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ABNServer).Lookup(ctx, req.(*Application))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _ABN_WriteMetric_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(MetricValue)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ABNServer).WriteMetric(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/main.ABN/WriteMetric",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ABNServer).WriteMetric(ctx, req.(*MetricValue))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _ABN_GetApplicationData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ApplicationRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ABNServer).GetApplicationData(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/main.ABN/GetApplicationData",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ABNServer).GetApplicationData(ctx, req.(*ApplicationRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// ABN_ServiceDesc is the grpc.ServiceDesc for ABN service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var ABN_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "main.ABN",
+	HandlerType: (*ABNServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "Lookup",
+			Handler:    _ABN_Lookup_Handler,
+		},
+		{
+			MethodName: "WriteMetric",
+			Handler:    _ABN_WriteMetric_Handler,
+		},
+		{
+			MethodName: "GetApplicationData",
+			Handler:    _ABN_GetApplicationData_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "abn/grpc/abn.proto",
+}
diff --git a/abn/k8sclient/doc.go b/abn/k8sclient/doc.go
new file mode 100644
index 00000000..62d86730
--- /dev/null
+++ b/abn/k8sclient/doc.go
@@ -0,0 +1,2 @@
+// package k8sclient enables interaction with Kubernetes using both a typed client and a dynamic client
+package k8sclient
diff --git a/abn/k8sclient/k8sclient.go b/abn/k8sclient/k8sclient.go
new file mode 100644
index 00000000..4293c454
--- /dev/null
+++ b/abn/k8sclient/k8sclient.go
@@ -0,0 +1,74 @@
+package k8sclient
+
+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"
+)
+
+// Client is a global variable. Before use, it must be assigned and initalized
+var Client KubeClient
+
+// KubeClient embeds Kube configuration, and
+// enables interaction with a Kubernetes cluster through Kube APIs
+type KubeClient struct {
+	// EnvSettings provides generic Kubernets and Helm options; while Helm is not needed
+	// for A/B(/n) functionality, we use here since we do so in other places and it
+	// provides an easy way to get the Kubernetes configuration whether in cluster or not.
+	*cli.EnvSettings
+	// typedClient enables interaction with a Kubernetes cluster using structured types
+	typedClient 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
+	}
+}
+
+// initKube initializes the Kubernetes clientset
+func (c *KubeClient) Initialize() (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.typedClient, 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.typedClient
+}
+
+func (c *KubeClient) Dynamic() dynamic.Interface {
+	return c.dynamicClient
+}
diff --git a/abn/watcher/doc.go b/abn/watcher/doc.go
new file mode 100644
index 00000000..af913d8f
--- /dev/null
+++ b/abn/watcher/doc.go
@@ -0,0 +1,19 @@
+// Package watcher provides methods to watch resources and build a runtime model of applications
+
+package watcher
+
+// Configured resource types are watched in a set of namespaces. Each version of an application
+// of interest should be include EXACTLY one watched resource with the following metadata:
+//  - label 'iter8.tools/abn' set to true indicating the resource should be inspected further
+//  - label 'app.kubernetes.io/name' identifying the name of the application to which the resource belongs
+//  - label 'app.kubernetes.io/version' identifying the name of the version  to which the resource belongs
+//
+// It may also include the following metadata:
+//  - annotation 'iter8.tools/ready' indicating the version is ready to receive traffic (default: false)
+//  - annotation 'iter8.tools/track' defining the track label that should be associated with the version (no track when not present)
+
+// A track is a user assigned identifier. When the Iter8 A/N(/n) service is used to lookup versions,
+// the track identifier is returned (instead of a version name). The caller can use the the track
+// identifier to route requests to the appropriate version. To do this, the set of track identifiers
+// should be a (small) fixed set, such as "current" and "candidate", that can be mapped to a set of
+// static routes.
diff --git a/abn/watcher/handler.go b/abn/watcher/handler.go
new file mode 100644
index 00000000..27ed510d
--- /dev/null
+++ b/abn/watcher/handler.go
@@ -0,0 +1,103 @@
+package watcher
+
+import (
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	"github.com/iter8-tools/iter8/base/log"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/selection"
+	"k8s.io/client-go/dynamic/dynamicinformer"
+)
+
+// precond is set of preconditions that must hold true before an object is considered.
+// It must have:
+//  - label 'iter8.tools/abn' set to true indicating the resource should be inspected further
+//  - label 'app.kubernetes.io/name' identifying the name of the application to which the resource belongs
+//  - label 'app.kubernetes.io/version' identifying the name of the version  to which the resource belongs
+func precond(w watchedObject) bool {
+	var ok bool
+
+	if !w.isIter8AbnRelated() {
+		return false
+	}
+
+	application, ok := w.getNamespacedName()
+	if !ok || application == "" {
+		return false
+	}
+
+	version, ok := w.getVersion()
+	if !ok || version == "" {
+		return false
+	}
+
+	return true
+}
+
+// handle constructs the application object from the objects currently in the cluster
+func handle(w watchedObject, resourceTypes []schema.GroupVersionResource, informerFactories map[string]dynamicinformer.DynamicSharedInformerFactory) {
+	application, _ := w.getNamespacedName()
+	namespace := w.getNamespace()
+	name, _ := w.getName()
+
+	applicationObjs := getApplicationObjects(namespace, name, resourceTypes, informerFactories)
+	// there is at least one object (w)
+
+	a, _ := abnapp.Applications.Read(application)
+
+	abnapp.Applications.Lock(application)
+	// clear a.Tracks, a.Versions[*].Track
+	// this is necessary because  we keep old versions in memory
+	a.ClearTracks()
+
+	for _, o := range applicationObjs {
+		version, _ := o.getVersion()
+		a.GetVersion(version, true) // make sure version object created
+		track := o.getTrack()
+		if track != "" {
+			a.Tracks[track] = version
+		}
+	}
+	abnapp.Applications.Unlock(application)
+}
+
+// getApplicationObjects gets all the objects related to the application based on label app.kubernetes.io/name
+func getApplicationObjects(namespace, name string, gvrs []schema.GroupVersionResource, informerFactories map[string]dynamicinformer.DynamicSharedInformerFactory) []watchedObject {
+	// define selector
+	selector := labels.NewSelector()
+	reqSpec := []struct {
+		key  string
+		op   selection.Operator
+		vals []string
+	}{
+		{key: iter8Label, op: selection.Equals, vals: []string{"true"}},
+		{key: nameLabel, op: selection.Equals, vals: []string{name}},
+		{key: versionLabel, op: selection.Exists, vals: []string{}},
+	}
+	for _, rs := range reqSpec {
+		req, err := labels.NewRequirement(rs.key, rs.op, rs.vals)
+		if err != nil {
+			log.Logger.Warn(err)
+			return []watchedObject{}
+		}
+		selector = selector.Add(*req)
+	}
+
+	watchedObjects := []watchedObject{}
+	for _, gvr := range gvrs {
+		lister := informerFactories[namespace].ForResource(gvr).Lister()
+		objs, err := lister.List(selector)
+		if err != nil {
+			log.Logger.Warn(err)
+			continue
+		}
+		for _, obj := range objs {
+			wo := watchedObject{Obj: obj.(*unstructured.Unstructured)}
+			if precond(wo) {
+				watchedObjects = append(watchedObjects, wo)
+			}
+		}
+	}
+	return watchedObjects
+}
diff --git a/abn/watcher/informer.go b/abn/watcher/informer.go
new file mode 100644
index 00000000..d92ddc5d
--- /dev/null
+++ b/abn/watcher/informer.go
@@ -0,0 +1,52 @@
+package watcher
+
+// informer.go - informer(s) to watch desired resources/namespaces
+
+import (
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/dynamic/dynamicinformer"
+	"k8s.io/client-go/tools/cache"
+)
+
+type iter8Watcher struct {
+	factories map[string]dynamicinformer.DynamicSharedInformerFactory
+}
+
+// NewIter8Watcher returns a watcher for iter8 related objects
+func NewIter8Watcher(resourceTypes []schema.GroupVersionResource, namespaces []string) *iter8Watcher {
+	w := &iter8Watcher{
+		factories: map[string]dynamicinformer.DynamicSharedInformerFactory{},
+	}
+
+	handlerFunc := func(obj interface{}) {
+		wo := watchedObject{Obj: obj.(*unstructured.Unstructured)}
+		if precond(wo) {
+			handle(wo, resourceTypes, w.factories)
+		}
+	}
+
+	// for each namespace, resource type configure Informer
+	for _, ns := range namespaces {
+		w.factories[ns] = dynamicinformer.NewFilteredDynamicSharedInformerFactory(k8sclient.Client.Dynamic(), 0, ns, nil)
+		for _, gvr := range resourceTypes {
+			informer := w.factories[ns].ForResource(gvr)
+			informer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+				AddFunc: handlerFunc,
+				UpdateFunc: func(oldObj, obj interface{}) {
+					handlerFunc(obj)
+				},
+				DeleteFunc: handlerFunc,
+			})
+		}
+	}
+	return w
+}
+
+// Start starts the watcher
+func (watcher *iter8Watcher) Start(stopChannel chan struct{}) {
+	for _, f := range watcher.factories {
+		f.Start(stopChannel)
+	}
+}
diff --git a/abn/watcher/watchedobject.go b/abn/watcher/watchedobject.go
new file mode 100644
index 00000000..ff369a14
--- /dev/null
+++ b/abn/watcher/watchedobject.go
@@ -0,0 +1,66 @@
+package watcher
+
+// watchedobject.go - methods to read fields from an unstructured Kubernetes object
+
+import (
+	"strings"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+const (
+	nameLabel    = "app.kubernetes.io/name"
+	versionLabel = "app.kubernetes.io/version"
+	trackLabel   = "iter8.tools/track"
+	iter8Label   = "iter8.tools/abn"
+)
+
+// watchedObject is wrapper for object returned by informer
+type watchedObject struct {
+	// Obj is the Kubernetes object
+	Obj *unstructured.Unstructured
+}
+
+// getName gets application name from NAME_LABEL label on watched object
+func (wo watchedObject) getName() (string, bool) {
+	labels := wo.Obj.GetLabels()
+	name, ok := labels[nameLabel]
+	return name, ok
+}
+
+// getNamespace gets namespace of watched object
+func (wo watchedObject) getNamespace() string {
+	return wo.Obj.GetNamespace()
+}
+
+// getNamespacedName returns formatted namespace and (application) name
+func (wo watchedObject) getNamespacedName() (string, bool) {
+	n, ok := wo.getName()
+	return wo.getNamespace() + "/" + n, ok
+}
+
+// getVersion gets application version from VERSION_LABEL label on watched object
+func (wo watchedObject) getVersion() (string, bool) {
+	labels := wo.Obj.GetLabels()
+	v, ok := labels[versionLabel]
+	return v, ok
+}
+
+// getTrack get trace of version from TRACK_ANNOTATION annotation on watched object
+func (wo watchedObject) getTrack() string {
+	labels := wo.Obj.GetLabels()
+	track, ok := labels[trackLabel]
+	if !ok {
+		return ""
+	}
+	return track
+}
+
+func (wo watchedObject) isIter8AbnRelated() bool {
+	labels := wo.Obj.GetLabels()
+	iter8, ok := labels[iter8Label]
+	if !ok {
+		return false
+	}
+	return strings.ToLower(iter8) == "true"
+}
diff --git a/cmd/abn.go b/cmd/abn.go
new file mode 100644
index 00000000..af8f89a5
--- /dev/null
+++ b/cmd/abn.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+	abn "github.com/iter8-tools/iter8/abn/core"
+
+	"github.com/spf13/cobra"
+)
+
+// abnDesc is the description of abn cmd
+const abnDesc = `
+Run the Iter8 A/B(/n) service.
+
+	iter8 abn
+`
+
+// newAbnCmd creates the abn command
+func newAbnCmd() *cobra.Command {
+
+	cmd := &cobra.Command{
+		Use:   "abn",
+		Short: "Start the Iter8 A/B(/n) service",
+		Long:  abnDesc,
+		RunE: func(_ *cobra.Command, _ []string) error {
+			abn.Start()
+			return nil
+		},
+		SilenceUsage: true,
+		Hidden:       true,
+	}
+	return cmd
+}
+
+// initialize with assert
+func init() {
+	rootCmd.AddCommand(newAbnCmd())
+}


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 869262d1..6faa068e 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -19,10 +19,10 @@ jobs:
       run: |
         export COVERAGE=$(go tool cover -func coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}')
         echo "code coverage is at ${COVERAGE}"
-        if [ 1 -eq "$(echo "${COVERAGE} > 74.0" | bc)" ]; then \
-          echo "all good... coverage is above 74.0%"; 
+        if [ 1 -eq "$(echo "${COVERAGE} > 75.0" | bc)" ]; then \
+          echo "all good... coverage is above 75.0%"; 
         else \
-          echo "not good... coverage is not above 74.0%";
+          echo "not good... coverage is not above 75.0%";
           exit 1
         fi
 
diff --git a/abn/application/application_test.go b/abn/application/application_test.go
new file mode 100644
index 00000000..81f6033d
--- /dev/null
+++ b/abn/application/application_test.go
@@ -0,0 +1,313 @@
+package application
+
+import (
+	"testing"
+	"time"
+
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/stretchr/testify/assert"
+
+	"helm.sh/helm/v3/pkg/cli"
+)
+
+func TestGetAndRead(t *testing.T) {
+	app := applicationAssertion{
+		namespace: "default",
+		name:      "application",
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2"},
+	}
+	defaultApp := applicationAssertion{
+		namespace: "default",
+		name:      "application",
+		tracks:    []string{},
+		versions:  []string{},
+	}
+	scenarios := map[string]struct {
+		fetch         func(name string) (*Application, error)
+		setup         func(t *testing.T, applications ...applicationSource)
+		errorContains string
+		isNil         bool
+		appSpec       *applicationAssertion
+	}{
+		"GET in memory, in cluster":          {setup: setupInMemoryInCluster, fetch: Applications.Get, isNil: false, appSpec: &app, errorContains: ""},
+		"READ in memory, in cluster":         {setup: setupInMemoryInCluster, fetch: Applications.Read, isNil: false, appSpec: &app, errorContains: ""},
+		"GET in memory, not in cluster":      {setup: setupInMemoryNotInCluster, fetch: Applications.Get, isNil: false, appSpec: &app, errorContains: ""},
+		"READ in memory, not in cluster":     {setup: setupInMemoryNotInCluster, fetch: Applications.Read, isNil: false, appSpec: &app, errorContains: ""},
+		"GET not in memory, in cluster":      {setup: setupNotInMemoryInCluster, fetch: Applications.Get, isNil: true, appSpec: nil, errorContains: "not in memory"},
+		"READ not in memory, in cluster":     {setup: setupNotInMemoryInCluster, fetch: Applications.Read, isNil: false, appSpec: &app, errorContains: ""},
+		"GET not in memory, not in cluster":  {setup: setupNotInMemoryNotInCluster, fetch: Applications.Get, isNil: true, appSpec: nil, errorContains: "not in memory"},
+		"READ not in memory, not in cluster": {setup: setupNotInMemoryNotInCluster, fetch: Applications.Read, isNil: false, appSpec: &defaultApp, errorContains: "not found"},
+	}
+
+	for label, s := range scenarios {
+		t.Run(label, func(t *testing.T) {
+			s.setup(t, applicationSource{namespace: "default", name: "application", folder: testdata, file: testfile})
+			a, err := s.fetch("default/application")
+			if s.errorContains != "" {
+				assert.Error(t, err)
+				assert.ErrorContains(t, err, s.errorContains)
+			} else {
+				assert.NoError(t, err)
+			}
+			if s.appSpec == nil {
+				assert.Nil(t, a)
+			} else {
+				assert.NotNil(t, a)
+				assertApplication(t, a, *s.appSpec)
+				writeVerify(t, a)
+			}
+		})
+	}
+}
+
+func TestWrite(t *testing.T) {
+	setupInMemoryInCluster(t, applicationSource{namespace: "default", name: "application", folder: testdata, file: testfile})
+
+	a, _ := Applications.readFromSecret("default/application")
+	assertApplication(t, a, applicationAssertion{
+		namespace: "default",
+		name:      "application",
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2"},
+	})
+
+	// modify application in some way
+	a.Tracks["foo"] = "v1"
+
+	// Write writes immediately
+	Applications.Write(a)
+	b, _ := Applications.readFromSecret("default/application")
+	// changed
+	assertApplication(t, b, applicationAssertion{
+		namespace: "default",
+		name:      "application",
+		tracks:    []string{"candidate", "foo"},
+		versions:  []string{"v1", "v2"},
+	})
+}
+
+func TestWriteLimit(t *testing.T) {
+	setupInMemoryInCluster(t, applicationSource{namespace: "default", name: "application", folder: testdata, file: testfile})
+	BatchWriteInterval = time.Duration(0)
+	maxApplicationDataBytes = 100
+
+	a, err := Applications.readFromSecret("default/application")
+	assert.NoError(t, err)
+	assert.NotNil(t, a)
+
+	// v1 is not associated with a track; only v2 is
+	assert.NotEqual(t, len(a.Tracks), len(a.Versions))
+
+	// because maxApplicationDatBytes is so small, should delete v1
+	err = Applications.Write(a)
+	assert.NoError(t, err)
+
+	b, err := Applications.readFromSecret("default/application")
+	assert.NoError(t, err)
+	assert.NotNil(t, b)
+
+	// only v2 is present
+	assert.Equal(t, len(b.Tracks), len(b.Versions))
+}
+
+func TestBatchedWrite(t *testing.T) {
+	ns := "test"
+	nm := "batchedwrite"
+	setupInMemoryInCluster(t, applicationSource{namespace: ns, name: nm, folder: testdata, file: testfile})
+	BatchWriteInterval = time.Duration(2 * time.Second)
+
+	a, _ := Applications.Get(ns + "/" + nm)
+	assertApplication(t, a, applicationAssertion{
+		namespace: ns,
+		name:      nm,
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2"},
+	})
+
+	// modify application in some way
+	a.Tracks["foo"] = "v1"
+
+	// BatchedWrite should not write; too soon
+	Applications.BatchedWrite(a)
+	b, _ := Applications.readFromSecret(ns + "/" + nm)
+	// no change; it has been too soon
+	assertApplication(t, b, applicationAssertion{
+		namespace: ns,
+		name:      nm,
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2"},
+	})
+
+	time.Sleep((250 * time.Millisecond) + BatchWriteInterval)
+
+	// BatchedWrite should succeed; we waited > BatchWriteInterval
+	Applications.BatchedWrite(a)
+	c, _ := Applications.Read(ns + "/" + nm)
+	// changed
+	assertApplication(t, c, applicationAssertion{
+		namespace: ns,
+		name:      nm,
+		tracks:    []string{"candidate", "foo"},
+		versions:  []string{"v1", "v2"},
+	})
+}
+
+const testdata string = "../../testdata"
+const testfile string = "abninputs/readtest.yaml"
+
+func TestGetVersion(t *testing.T) {
+	ns := "test"
+	nm := "getversion"
+	setupInMemoryInCluster(t, applicationSource{namespace: ns, name: nm, folder: testdata, file: testfile})
+	a, _ := Applications.Read(ns + "/" + nm)
+	// verify it is as expected
+	assertApplication(t, a, applicationAssertion{
+		namespace: ns,
+		name:      nm,
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2"},
+	})
+
+	var v *Version
+	var isNew bool
+
+	// get a version that exists
+	v, _ = a.GetVersion("v1", false)
+	assertVersion(t, v, versionAssertion{
+		track:   "",
+		metrics: []string{"metric1"},
+	})
+
+	// get a version that doesn't exist without allowing new creation
+	v, isNew = a.GetVersion("foo", false)
+	assert.Nil(t, v)
+	assert.False(t, isNew)
+
+	// get a version that doesn't exist allowing new creation
+	v, isNew = a.GetVersion("foo", true)
+	assert.NotNil(t, v)
+	assert.True(t, isNew)
+	assertApplication(t, a, applicationAssertion{
+		namespace: ns,
+		name:      nm,
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2", "foo"},
+	})
+
+	// b := writeVerify(t, a)
+
+	application := a.Name
+	// write application to cluster (should create the secret, if not present)
+	err := Applications.Write(a)
+	assert.NoError(t, err)
+	// read back from cluster; clear Applications first so actually read from secret
+	Applications.Clear()
+	a, err = Applications.Read(application)
+	assert.NotNil(t, a)
+	assert.NoError(t, err)
+
+	// verify version foo is now present
+	assertApplication(t, a, applicationAssertion{
+		namespace: ns,
+		name:      nm,
+		tracks:    []string{"candidate"},
+		versions:  []string{"v1", "v2", "foo"},
+	})
+}
+
+func TestVersionAndSummaryMetric(t *testing.T) {
+	var m *SummaryMetric
+	var isNew bool
+
+	v := &Version{
+		Metrics: map[string]*SummaryMetric{},
+	}
+
+	// test GetMetic w/o allowNew
+	m, isNew = v.GetMetric("foo", false)
+	assert.Nil(t, m)
+	assert.False(t, isNew)
+
+	// and with allowNew
+	m, isNew = v.GetMetric("foo", true)
+	assert.NotNil(t, m)
+	assert.True(t, isNew)
+	// new metric is empty
+	assert.Equal(t, uint32(0), m.Count())
+	assert.Equal(t, float64(0), m.Sum())
+
+	// add values
+	m.Add(float64(27))
+	m.Add(float64(56))
+	assert.Equal(t, uint32(2), m.Count())
+	assert.Equal(t, float64(27), m.Min())
+	assert.Equal(t, float64(56), m.Max())
+	assert.Equal(t, float64(83), m.Sum())
+	assert.Equal(t, float64(3865), m.SumSquares())
+	assert.Equal(t, "[2] 27.000000, 56.000000, 83.000000, 3865.000000", m.String())
+
+	// try again
+	m, isNew = v.GetMetric("foo", false)
+	assert.NotNil(t, m)
+	assert.False(t, isNew)
+	assert.Equal(t, uint32(2), m.Count())
+
+	m, isNew = v.GetMetric("foo", true)
+	assert.NotNil(t, m)
+	assert.False(t, isNew)
+	assert.Equal(t, uint32(2), m.Count())
+	assert.Equal(t, float64(3865), m.SumSquares())
+}
+
+type applicationSource struct {
+	namespace string
+	name      string
+	folder    string
+	file      string
+}
+
+func setupInitialization(t *testing.T) {
+	k8sclient.Client = *k8sclient.NewFakeKubeClient(cli.New())
+	Applications.Clear()
+	maxApplicationDataBytes = defaultMaxApplicationDataBytes
+	BatchWriteInterval = defaultBatchWriteInterval
+}
+
+func setupNotInMemoryNotInCluster(t *testing.T, applications ...applicationSource) {
+	setupInitialization(t)
+}
+
+func setupNotInMemoryInCluster(t *testing.T, applications ...applicationSource) {
+	setupInitialization(t)
+	for _, aSrc := range applications {
+		yamlToSecret(aSrc.folder, aSrc.file, aSrc.namespace+"/"+aSrc.name)
+	}
+}
+
+func setupInMemoryInCluster(t *testing.T, applications ...applicationSource) {
+	setupNotInMemoryInCluster(t, applications...)
+	for _, aSrc := range applications {
+		a, err := Applications.Read(aSrc.namespace + "/" + aSrc.name)
+		assert.NoError(t, err)
+		assert.NotNil(t, a)
+	}
+}
+
+func setupInMemoryNotInCluster(t *testing.T, applications ...applicationSource) {
+	setupInMemoryInCluster(t, applications...)
+	k8sclient.Client = *k8sclient.NewFakeKubeClient(cli.New())
+}
+
+func writeVerify(t *testing.T, a *Application) *Application {
+	application := a.Name
+	// write application to cluster (should create the secret, if not present)
+	err := Applications.Write(a)
+	assert.NoError(t, err)
+
+	// verify can read it back
+	a, err = Applications.readFromSecret(application)
+	assert.NotNil(t, a)
+	assert.NoError(t, err)
+	return a
+}
diff --git a/abn/application/test_helper.go b/abn/application/test_helper.go
new file mode 100644
index 00000000..5777a1de
--- /dev/null
+++ b/abn/application/test_helper.go
@@ -0,0 +1,113 @@
+package application
+
+import (
+	"io/ioutil"
+	"path/filepath"
+	"runtime"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/yaml"
+)
+
+func yamlToSecret(folder, file, name string) error {
+	a, _ := yamlToApplication(name, folder, file)
+	return Applications.Write(a)
+}
+
+func readYamlFromFile(folder, file string) ([]byte, error) {
+	_, filename, _, _ := runtime.Caller(1) // one step up the call stack
+	fname := filepath.Join(filepath.Dir(filename), folder, file)
+
+	return ioutil.ReadFile(fname)
+}
+
+func yamlToApplication(name, folder, file string) (*Application, error) {
+	byteArray, err := readYamlFromFile(folder, file)
+	if err != nil {
+		return nil, err
+	}
+
+	return byteArrayToApplication(name, byteArray)
+}
+
+func byteArrayToApplication(name string, data []byte) (*Application, error) {
+	a := &Application{}
+	err := yaml.Unmarshal(data, a)
+	if err != nil {
+		return NewApplication(name), nil
+	}
+	a.Name = name
+
+	// Initialize versions if not already initialized
+	if a.Versions == nil {
+		a.Versions = Versions{}
+	}
+	for _, v := range a.Versions {
+		if v.Metrics == nil {
+			v.Metrics = map[string]*SummaryMetric{}
+		}
+	}
+
+	return a, nil
+}
+
+type applicationAssertion struct {
+	namespace, name  string
+	tracks, versions []string
+}
+
+func assertApplication(t *testing.T, a *Application, assertion applicationAssertion) bool {
+	r := true
+	r = r && assert.NotNil(t, a)
+	r = r && assert.Contains(t, a.String(), assertion.namespace+"/"+assertion.name)
+
+	namespace, name := splitApplicationKey(a.Name)
+	r = r && assert.Equal(t, assertion.name, name)
+	r = r && assert.Equal(t, assertion.namespace, namespace)
+
+	r = r && assert.Len(t, a.Tracks, len(assertion.tracks))
+	for _, track := range assertion.tracks {
+		r = r && assert.Contains(t, a.Versions, a.Tracks[track])
+	}
+	r = r && assert.Len(t, a.Versions, len(assertion.versions))
+
+	for _, v := range a.Versions {
+		r = r && assert.NotNil(t, v.Metrics)
+	}
+
+	return r
+}
+
+type versionAssertion struct {
+	track   string
+	metrics []string
+}
+
+func assertVersion(t *testing.T, v *Version, assertion versionAssertion) bool {
+	r := true
+
+	r = r && assert.NotNil(t, v)
+
+	r = r && assert.Len(t, v.Metrics, len(assertion.metrics))
+	r = r && assert.NotNil(t, v.Metrics)
+	for m := range v.Metrics {
+		r = r && assert.Contains(t, assertion.metrics, m)
+	}
+	return r
+}
+
+// Clear the application map
+func (m *ThreadSafeApplicationMap) Clear() {
+	m.mutex.Lock()
+	m.apps = map[string]*Application{}
+	m.lastWriteTimes = map[string]*time.Time{}
+	m.mutexes = map[string]*sync.RWMutex{}
+	m.mutex.Unlock()
+}
+
+func NumApplications(t *testing.T, length int) bool {
+	return assert.Len(t, Applications.apps, length)
+}
diff --git a/abn/core/config_test.go b/abn/core/config_test.go
new file mode 100644
index 00000000..946c831f
--- /dev/null
+++ b/abn/core/config_test.go
@@ -0,0 +1,36 @@
+package core
+
+import (
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestReadConfig(t *testing.T) {
+	for _, tt := range []struct {
+		name          string
+		file          string
+		numNamespaces int
+		numResources  int
+	}{
+		{"empty", "config.empty.yaml", 0, 0},
+		{"nonamespaces", "config.nonamespaces.yaml", 0, 1},
+		{"invalid", "config.invalid.yaml", 0, 0},
+		{"garbage", "config.garbage.yaml", 0, 0},
+		{"nofile", "config.nofile.yaml", 0, 0},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			c := readConfig(completePath("../../testdata/abninputs", tt.file))
+			assert.Equal(t, tt.numNamespaces, len(c.Namespaces))
+			assert.Equal(t, tt.numResources, len(c.Resources))
+		})
+	}
+}
+
+// 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)
+}
diff --git a/abn/core/grpc_service_test.go b/abn/core/grpc_service_test.go
new file mode 100644
index 00000000..82c66862
--- /dev/null
+++ b/abn/core/grpc_service_test.go
@@ -0,0 +1,237 @@
+package core
+
+import (
+	"context"
+	"io/ioutil"
+	"net"
+	"path/filepath"
+	"runtime"
+	"testing"
+	"time"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	pb "github.com/iter8-tools/iter8/abn/grpc"
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/stretchr/testify/assert"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+	"helm.sh/helm/v3/pkg/cli"
+	"sigs.k8s.io/yaml"
+)
+
+type Scenario struct {
+	// parameters to lookup
+	application string
+	user        string
+	// expected results for lookup scenarios
+	errorSubstring string
+	track          string
+	// additional expected results for writemetric scenarios
+	metric string
+	value  string
+}
+
+func TestLookup(t *testing.T) {
+	testcases := map[string]Scenario{
+		"no applicaton": {application: "default/noapp", user: "foobar", errorSubstring: "application not found", track: ""},
+		"no user":       {application: "default/application", user: "", errorSubstring: "no user session provided", track: ""},
+		"valid":         {application: "default/application", user: "user", errorSubstring: "", track: "candidate"},
+	}
+
+	for label, scenario := range testcases {
+		t.Run(label, func(t *testing.T) {
+			client, teardown := setup(t)
+			defer teardown()
+			testLookup(t, client, scenario)
+		})
+	}
+}
+
+func testLookup(t *testing.T, client *pb.ABNClient, scenario Scenario) {
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	s, err := (*client).Lookup(
+		ctx,
+		&pb.Application{
+			Name: scenario.application,
+			User: scenario.user,
+		},
+	)
+
+	if scenario.errorSubstring != "" {
+		assert.Error(t, err)
+		assert.ErrorContains(t, err, scenario.errorSubstring)
+	} else {
+		assert.NoError(t, err)
+
+		assert.NotNil(t, s)
+		assert.Equal(t, scenario.track, s.GetTrack())
+	}
+}
+
+func TestGetApplicationData(t *testing.T) {
+	client, teardown := setup(t)
+	defer teardown()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	s, err := (*client).GetApplicationData(
+		ctx,
+		&pb.ApplicationRequest{
+			Application: "default/application",
+		},
+	)
+
+	assert.NoError(t, err)
+	assert.NotNil(t, s)
+	jsonStr := s.GetApplicationJson()
+	assert.Equal(t, "{\"name\":\"default/application\",\"tracks\":{\"candidate\":\"v2\"},\"versions\":{\"v1\":{\"metrics\":{\"metric1\":[1,45,45,45,2025]}},\"v2\":{\"metrics\":{}}}}", jsonStr)
+}
+
+func TestWriteMetric(t *testing.T) {
+	testcases := map[string]Scenario{
+		"no applicaton": {application: "", user: "user", errorSubstring: "track not mapped", track: "", metric: "", value: "76"},
+		"no user":       {application: "default/application", user: "", errorSubstring: "no user session provided", track: "", metric: "", value: "76"},
+		"invalid value": {application: "default/application", user: "user", errorSubstring: "", track: "", metric: "", value: "abc"},
+		"valid":         {application: "default/application", user: "user", errorSubstring: "", track: "candidate", metric: "metric1", value: "76"},
+	}
+
+	for label, scenario := range testcases {
+		t.Run(label, func(t *testing.T) {
+			client, teardown := setup(t)
+			defer teardown()
+			abnapp.BatchWriteInterval = time.Duration(0)
+			testWriteMetric(t, client, scenario)
+		})
+	}
+}
+
+func testWriteMetric(t *testing.T, client *pb.ABNClient, scenario Scenario) {
+	// get current count of metric
+	var oldCount uint32 = 0
+	var a *abnapp.Application
+	a, err := abnapp.Applications.Get(scenario.application)
+	if scenario.application == "" {
+		assert.ErrorContains(t, err, "not in memory")
+		return
+	}
+	assert.NotNil(t, a)
+	abnapp.Applications.RLock(a.Name)
+	if scenario.metric != "" {
+		m := getMetric(a, scenario.track, scenario.metric)
+		assert.NotNil(t, m)
+		oldCount = m.Count()
+	}
+	abnapp.Applications.RUnlock(a.Name)
+
+	// call gRPC service WriteMetric()
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	_, err = (*client).WriteMetric(
+		ctx,
+		&pb.MetricValue{
+			Name:        scenario.metric,
+			Value:       scenario.value,
+			Application: scenario.application,
+			User:        scenario.user,
+		},
+	)
+
+	if scenario.errorSubstring != "" {
+		assert.ErrorContains(t, err, scenario.errorSubstring)
+	} else {
+		assert.NoError(t, err) // never any errors
+	}
+
+	// verify that metric count has increased by 1
+	a, _ = abnapp.Applications.Get(scenario.application)
+	assert.NotNil(t, a)
+	abnapp.Applications.RLock(a.Name)
+	if scenario.metric != "" {
+		m := getMetric(a, scenario.track, scenario.metric)
+		assert.NotNil(t, m)
+		assert.Equal(t, oldCount+1, m.Count())
+	}
+	abnapp.Applications.RUnlock(a.Name)
+}
+
+func setup(t *testing.T) (*pb.ABNClient, func()) {
+	k8sclient.Client = *k8sclient.NewFakeKubeClient(cli.New())
+	// populate watcher.Applications with test applications
+	abnapp.Applications.Clear()
+	a, err := yamlToApplication("default/application", "../../testdata", "abninputs/readtest.yaml")
+	assert.NoError(t, err)
+	abnapp.Applications.Put(a)
+
+	// start server
+	lis, err := net.Listen("tcp", ":0")
+	assert.NoError(t, err)
+
+	serverOptions := []grpc.ServerOption{}
+	grpcServer := grpc.NewServer(serverOptions...)
+	pb.RegisterABNServer(grpcServer, newServer())
+	go grpcServer.Serve(lis)
+
+	// setup client
+	opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
+	conn, err := grpc.Dial(lis.Addr().String(), opts...)
+	assert.NoError(t, err)
+
+	c := pb.NewABNClient(conn)
+
+	// return client and teardown function to clean up
+	return &c, func() {
+		grpcServer.Stop()
+		lis.Close()
+		conn.Close()
+	}
+}
+
+func yamlToApplication(name, folder, file string) (*abnapp.Application, error) {
+	byteArray, err := readYamlFromFile(folder, file)
+	if err != nil {
+		return nil, err
+	}
+
+	return byteArrayToApplication(name, byteArray)
+}
+
+func readYamlFromFile(folder, file string) ([]byte, error) {
+	_, filename, _, _ := runtime.Caller(1) // one step up the call stack
+	fname := filepath.Join(filepath.Dir(filename), folder, file)
+	return ioutil.ReadFile(fname)
+}
+
+func byteArrayToApplication(name string, data []byte) (*abnapp.Application, error) {
+	a := &abnapp.Application{}
+	err := yaml.Unmarshal(data, a)
+	if err != nil {
+		return abnapp.NewApplication(name), nil
+	}
+	a.Name = name
+
+	// Initialize versions if not already initialized
+	if a.Versions == nil {
+		a.Versions = abnapp.Versions{}
+	}
+	for _, v := range a.Versions {
+		if v.Metrics == nil {
+			v.Metrics = map[string]*abnapp.SummaryMetric{}
+		}
+	}
+
+	return a, nil
+}
+
+func getMetric(a *abnapp.Application, track, metric string) *abnapp.SummaryMetric {
+	version, ok := a.Tracks[track]
+	if !ok {
+		return nil
+	}
+	v, _ := a.GetVersion(version, true)
+	m, _ := v.GetMetric(metric, true)
+	return m
+}
diff --git a/abn/k8sclient/test_helper.go b/abn/k8sclient/test_helper.go
new file mode 100644
index 00000000..fec3dfd7
--- /dev/null
+++ b/abn/k8sclient/test_helper.go
@@ -0,0 +1,52 @@
+package k8sclient
+
+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.typedClient = 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/abn/watcher/informer_test.go b/abn/watcher/informer_test.go
new file mode 100644
index 00000000..5de0668e
--- /dev/null
+++ b/abn/watcher/informer_test.go
@@ -0,0 +1,359 @@
+package watcher
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/stretchr/testify/assert"
+
+	"helm.sh/helm/v3/pkg/cli"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+func TestAdd(t *testing.T) {
+	scenarios := map[string]struct {
+		iter8related string
+		namespace    string
+		application  string
+		version      string
+		track        string
+	}{
+		"iter8 not set":  {iter8related: "", namespace: "namespace", application: "name", version: "version", track: "track"},
+		"iter8 not true": {iter8related: "false", namespace: "namespace", application: "name", version: "version", track: "track"},
+		"no application": {iter8related: "true", namespace: "namespace", application: "", version: "version", track: "track"},
+		"no version":     {iter8related: "true", namespace: "namespace", application: "name", version: "", track: "track"},
+		"no track":       {iter8related: "true", namespace: "namespace", application: "name", version: "version", track: ""},
+		"all":            {iter8related: "true", namespace: "namespace", application: "name", version: "version", track: "track"},
+	}
+
+	for label, s := range scenarios {
+		t.Run(label, func(t *testing.T) {
+			gvr := schema.GroupVersionResource{
+				Group:    "apps",
+				Version:  "v1",
+				Resource: "deployments",
+			}
+
+			done := make(chan struct{})
+			defer close(done)
+			setup(t, gvr, s.namespace, done)
+
+			createObject(t, gvr, s.iter8related, s.namespace, s.application, s.version, s.track)
+
+			assert.Eventually(
+				t,
+				func() bool {
+					r := true
+					// if any preconditions are not met, then no application added
+					if strings.ToLower(s.iter8related) != "true" ||
+						s.application == "" ||
+						s.version == "" {
+						r = r && abnapp.NumApplications(t, 0)
+						return r
+					}
+
+					// otherwise application created
+
+					a, err := abnapp.Applications.Get(s.namespace + "/" + s.application)
+					r = r && assert.NoError(t, err)
+					r = r && assert.NotNil(t, a)
+
+					tracks := []string{}
+					if s.track != "" {
+						tracks = []string{s.track}
+					}
+					r = r && assertApplication(t, a, applicationAssertion{
+						namespace: s.namespace,
+						name:      s.application,
+						tracks:    tracks,
+						versions:  []string{s.version},
+					})
+					return r
+				},
+				10*time.Second,
+				100*time.Millisecond,
+			)
+		})
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	scenarios := map[string]struct {
+		iter8related string
+		namespace    string
+		application  string
+		version      string
+		track        string
+	}{
+		"no application": {iter8related: "true", namespace: "namespace", application: "", version: "version", track: "track"},
+		"no version":     {iter8related: "true", namespace: "namespace", application: "name", version: "", track: "track"},
+		"no track":       {iter8related: "true", namespace: "namespace", application: "name", version: "version", track: ""},
+		"all":            {iter8related: "true", namespace: "namespace", application: "name", version: "version", track: "track"},
+	}
+
+	for label, s := range scenarios {
+		t.Run(label, func(t *testing.T) {
+			gvr := schema.GroupVersionResource{
+				Group:    "apps",
+				Version:  "v1",
+				Resource: "deployments",
+			}
+
+			done := make(chan struct{})
+			defer close(done)
+			setup(t, gvr, s.namespace, done)
+			existingObj := createObject(t, gvr, "true", "namespace", "name", "version", "")
+			// verify that existing object is as expected
+			assert.Eventually(
+				t,
+				func() bool {
+					r := true
+					a, err := abnapp.Applications.Get("namespace/name")
+					r = r && assert.NoError(t, err)
+					r = r && assert.NotNil(t, a)
+					r = r && assertApplication(t, a, applicationAssertion{
+						namespace: "namespace",
+						name:      "name",
+						tracks:    []string{},
+						versions:  []string{"version"},
+					})
+					return r
+				},
+				10*time.Second,
+				100*time.Millisecond,
+			)
+
+			//update object labels
+			updateLabel(existingObj, iter8Label, s.iter8related)
+			updateLabel(existingObj, nameLabel, s.application)
+			updateLabel(existingObj, versionLabel, s.version)
+			updateLabel(existingObj, trackLabel, s.track)
+
+			updatedObj, err := k8sclient.Client.Dynamic().
+				Resource(gvr).Namespace(s.namespace).
+				Update(
+					context.TODO(),
+					existingObj,
+					metav1.UpdateOptions{},
+				)
+			assert.NoError(t, err)
+			assert.NotNil(t, updatedObj)
+
+			assert.Eventually(
+				t,
+				func() bool {
+					r := true
+					// if any preconditions are not met, then no change was made to applications
+					if strings.ToLower(s.iter8related) != "true" ||
+						s.application == "" ||
+						s.version == "" {
+						// abnapp.NumApplications(t, 1)
+						a, err := abnapp.Applications.Get("namespace/name")
+						r = r && assert.NoError(t, err)
+						r = r && assert.NotNil(t, a)
+						r = r && assertApplication(t, a, applicationAssertion{
+							namespace: "namespace",
+							name:      "name",
+							tracks:    []string{},
+							versions:  []string{"version"},
+						})
+						return r
+					}
+
+					// otherwise application was possibily modified in some way
+					// at least this application exists since it was preexisting
+					a, err := abnapp.Applications.Get(s.namespace + "/" + s.application)
+					r = r && assert.NoError(t, err)
+					r = r && assert.NotNil(t, a)
+
+					tracks := []string{}
+					if s.track != "" {
+						tracks = []string{s.track}
+					}
+					r = r && assertApplication(t, a, applicationAssertion{
+						namespace: s.namespace,
+						name:      s.application,
+						tracks:    tracks,
+						versions:  []string{s.version},
+					})
+					return r
+				},
+				10*time.Second,
+				100*time.Millisecond,
+			)
+		})
+	}
+}
+
+func updateLabel(obj *unstructured.Unstructured, key string, value string) {
+	if value == "" {
+		delete((obj.Object["metadata"].(map[string]interface{}))["labels"].(map[string]interface{}), key)
+	} else {
+		(obj.Object["metadata"].(map[string]interface{}))["labels"].(map[string]interface{})[key] = value
+	}
+
+}
+
+func TestDelete(t *testing.T) {
+	scenarios := map[string]struct {
+		iter8related string
+		namespace    string
+		application  string
+		version      string
+		track        string
+	}{
+		"no track, ready": {iter8related: "true", namespace: "namespace", application: "name", version: "version", track: ""},
+		"track, ready":    {iter8related: "true", namespace: "namespace", application: "name", version: "version", track: "track"},
+	}
+
+	for label, s := range scenarios {
+		t.Run(label, func(t *testing.T) {
+			gvr := schema.GroupVersionResource{
+				Group:    "apps",
+				Version:  "v1",
+				Resource: "deployments",
+			}
+
+			done := make(chan struct{})
+			defer close(done)
+			setup(t, gvr, s.namespace, done)
+
+			// add existing object (to delete)
+			createObject(t, gvr, "true", "namespace", "name", "version", "track")
+			// verify that existing object is as expected
+			assert.Eventually(
+				t,
+				func() bool {
+					r := true
+					a, err := abnapp.Applications.Get("namespace/name")
+					r = r && assert.NoError(t, err)
+					r = r && assert.NotNil(t, a)
+					r = r && assertApplication(t, a, applicationAssertion{
+						namespace: "namespace",
+						name:      "name",
+						tracks:    []string{"track"},
+						versions:  []string{"version"},
+					})
+					return r
+				},
+				10*time.Second,
+				100*time.Millisecond,
+			)
+
+			// delete object --> no track anymore
+			err := k8sclient.Client.Dynamic().
+				Resource(gvr).Namespace(s.namespace).
+				Delete(
+					context.TODO(),
+					s.application,
+					metav1.DeleteOptions{},
+				)
+			assert.NoError(t, err)
+
+			assert.Eventually(
+				t,
+				func() bool {
+					r := true
+					// if any preconditions are not met, then no change was made to applications
+					if strings.ToLower(s.iter8related) != "true" ||
+						s.application == "" ||
+						s.version == "" {
+						a, err := abnapp.Applications.Get("namespace/name")
+						r = r && assert.NoError(t, err)
+						r = r && assert.NotNil(t, a)
+						r = r && assertApplication(t, a, applicationAssertion{
+							namespace: "namespace",
+							name:      "name",
+							tracks:    []string{"track"},
+							versions:  []string{"version"},
+						})
+						return r
+					}
+
+					// otherwise application was possibily modified in some way
+					// at least this application exists since it was preexisting
+					a, err := abnapp.Applications.Get(s.namespace + "/" + s.application)
+					r = r && assert.NoError(t, err)
+					r = r && assert.NotNil(t, a)
+
+					r = r && assertApplication(t, a, applicationAssertion{
+						namespace: s.namespace,
+						name:      s.application,
+						tracks:    []string{},
+						versions:  []string{s.version},
+					})
+
+					return r
+				},
+				10*time.Second,
+				100*time.Millisecond,
+			)
+		})
+	}
+}
+
+func setup(t *testing.T, gvr schema.GroupVersionResource, namespace string, done chan struct{}) {
+	// ensure global record of applications is empty
+	abnapp.Applications.Clear()
+
+	// define watcher
+	k8sclient.Client = *k8sclient.NewFakeKubeClient(cli.New())
+	w := NewIter8Watcher(
+		[]schema.GroupVersionResource{gvr},
+		[]string{namespace},
+	)
+	assert.NotNil(t, w)
+
+	// start informers
+	w.Start(done)
+}
+
+func createObject(t *testing.T, gvr schema.GroupVersionResource, iter8related, namespace, application, version, track string) *unstructured.Unstructured {
+	// create object; no track defined
+	createdObj, err := k8sclient.Client.Dynamic().
+		Resource(gvr).Namespace(namespace).
+		Create(
+			context.TODO(),
+			newUnstructuredDeployment(iter8related, namespace, application, version, track),
+			metav1.CreateOptions{},
+		)
+	assert.NoError(t, err)
+	assert.NotNil(t, createdObj)
+	return createdObj
+}
+
+func newUnstructuredDeployment(iter8related, namespace, application, version, track string) *unstructured.Unstructured {
+	labels := map[string]interface{}{}
+	if application != "" {
+		labels[nameLabel] = application
+	}
+	if version != "" {
+		labels[versionLabel] = version
+	}
+	if iter8related != "" {
+		labels[iter8Label] = iter8related
+	}
+
+	if track != "" {
+		labels[trackLabel] = track
+	}
+
+	return &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"apiVersion": "apps/v1",
+			"kind":       "Deployment",
+			"metadata": map[string]interface{}{
+				"namespace": namespace,
+				"name":      application,
+				"labels":    labels,
+			},
+			"spec": application,
+		},
+	}
+}
diff --git a/abn/watcher/test_helper.go b/abn/watcher/test_helper.go
new file mode 100644
index 00000000..26a65ae5
--- /dev/null
+++ b/abn/watcher/test_helper.go
@@ -0,0 +1,48 @@
+package watcher
+
+import (
+	"strings"
+	"testing"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	"github.com/stretchr/testify/assert"
+)
+
+type applicationAssertion struct {
+	namespace, name  string
+	tracks, versions []string
+}
+
+func assertApplication(t *testing.T, a *abnapp.Application, assertion applicationAssertion) bool {
+	r := true
+	r = r && assert.NotNil(t, a)
+	r = r && assert.Contains(t, a.String(), assertion.namespace+"/"+assertion.name)
+
+	namespace, name := splitApplicationKey(a.Name)
+	r = r && assert.Equal(t, assertion.name, name)
+	r = r && assert.Equal(t, assertion.namespace, namespace)
+
+	r = r && assert.Len(t, a.Tracks, len(assertion.tracks))
+	for _, track := range assertion.tracks {
+		r = r && assert.Contains(t, a.Versions, a.Tracks[track])
+	}
+	r = r && assert.Len(t, a.Versions, len(assertion.versions))
+
+	for _, v := range a.Versions {
+		r = r && assert.NotNil(t, v.Metrics)
+	}
+
+	return r
+}
+
+func splitApplicationKey(applicationName string) (string, string) {
+	var name, namespace string
+	names := strings.Split(applicationName, "/")
+	if len(names) > 1 {
+		namespace, name = names[0], names[1]
+	} else {
+		namespace, name = "default", names[0]
+	}
+
+	return namespace, name
+}
diff --git a/abn/watcher/watchedobject_test.go b/abn/watcher/watchedobject_test.go
new file mode 100644
index 00000000..5792aee4
--- /dev/null
+++ b/abn/watcher/watchedobject_test.go
@@ -0,0 +1,96 @@
+package watcher
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	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"
+)
+
+func TestHasName(t *testing.T) {
+	objName := "myobj"
+	objNamespace := "mynamespace"
+	appName := "myapp"
+	appVersion := "myversion"
+	appTrack := "myTrack"
+
+	p := corev1.Pod{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      objName,
+			Namespace: objNamespace,
+			Labels: map[string]string{
+				nameLabel:    appName,
+				versionLabel: appVersion,
+				trackLabel:   appTrack,
+			},
+		},
+		Spec: corev1.PodSpec{},
+	}
+	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&p)
+	assert.NoError(t, err)
+	wo := watchedObject{Obj: &unstructured.Unstructured{Object: obj}}
+
+	n, ok := wo.getName()
+	assert.True(t, ok)
+	assert.Equal(t, appName, n)
+
+	assert.Equal(t, objNamespace, wo.getNamespace())
+
+	nn, ok := wo.getNamespacedName()
+	assert.True(t, ok)
+	assert.Equal(t, objNamespace+"/"+appName, nn)
+
+	v, ok := wo.getVersion()
+	assert.True(t, ok)
+	assert.Equal(t, appVersion, v)
+
+	assert.Equal(t, appTrack, wo.getTrack())
+}
+
+func TestHasNoName(t *testing.T) {
+	objName := "myobj"
+	objNamespace := "mynamespace"
+
+	p := corev1.Pod{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      objName,
+			Namespace: objNamespace,
+		},
+		Spec: corev1.PodSpec{},
+	}
+	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&p)
+	assert.NoError(t, err)
+	wo := watchedObject{Obj: &unstructured.Unstructured{Object: obj}}
+
+	n, ok := wo.getName()
+	assert.False(t, ok)
+	assert.Empty(t, n)
+}
+
+func TestHasNoTrack(t *testing.T) {
+	objName := "myobj"
+	objNamespace := "mynamespace"
+	appName := "myapp"
+	appVersion := "myversion"
+
+	p := corev1.Pod{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      objName,
+			Namespace: objNamespace,
+			Labels: map[string]string{
+				nameLabel:    appName,
+				versionLabel: appVersion,
+			},
+		},
+		Spec: corev1.PodSpec{},
+	}
+	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&p)
+	assert.NoError(t, err)
+	wo := watchedObject{Obj: &unstructured.Unstructured{Object: obj}}
+
+	assert.Equal(t, "", wo.getTrack())
+}
diff --git a/testdata/abninputs/config.empty.yaml b/testdata/abninputs/config.empty.yaml
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/abninputs/config.garbage.yaml b/testdata/abninputs/config.garbage.yaml
new file mode 100644
index 00000000..8b0ce0c4
--- /dev/null
+++ b/testdata/abninputs/config.garbage.yaml
@@ -0,0 +1,1 @@
+invalid yaml file
\ No newline at end of file
diff --git a/testdata/abninputs/config.invalid.yaml b/testdata/abninputs/config.invalid.yaml
new file mode 100644
index 00000000..7daacd5d
--- /dev/null
+++ b/testdata/abninputs/config.invalid.yaml
@@ -0,0 +1,1 @@
+foo: bar
\ No newline at end of file
diff --git a/testdata/abninputs/config.nonamespaces.yaml b/testdata/abninputs/config.nonamespaces.yaml
new file mode 100644
index 00000000..7421c840
--- /dev/null
+++ b/testdata/abninputs/config.nonamespaces.yaml
@@ -0,0 +1,4 @@
+resources:
+- group: apps
+  version: v1
+  resource: deployments
diff --git a/testdata/abninputs/config.yaml b/testdata/abninputs/config.yaml
new file mode 100644
index 00000000..938d28b1
--- /dev/null
+++ b/testdata/abninputs/config.yaml
@@ -0,0 +1,8 @@
+resources:
+- group: apps
+  version: v1
+  resource: deployments
+- version: v1
+  resource: services
+namespaces:
+- default
\ No newline at end of file
diff --git a/testdata/abninputs/readtest.yaml b/testdata/abninputs/readtest.yaml
new file mode 100644
index 00000000..0d40243e
--- /dev/null
+++ b/testdata/abninputs/readtest.yaml
@@ -0,0 +1,13 @@
+name: default/application
+tracks:
+  candidate: v2
+versions:
+  v1:
+    metrics:
+      metric1:
+      - 1
+      - 45
+      - 45
+      - 45
+      - 2025
+  v2: {}