Repository: tetratelabs/getenvoy
Pull request #95 (2020-07-21 16:07:38)

Add postgres experience
Added logic to recognize _postgres_ flavor and bootstrap config based on template and command line parameters.

Command line parameters are specified by --templateArg. More than one templateArg may be specified. For Postgres a user must specify Endpoint, like --templateArg Endpoint=127.0.0.1

Additional parameter is InPort, which is Envoy's listening port. If not specified the value of InPort defaults to 5432.
To overwrite InPort, cmd line should contain --templateArg InPort=5433

musaprg: `getenvoy run postgres:nightly --templateArg endpoint=127.0.0.1` got errors below

```
[2020-07-22 18:43:38.479][39037][critical][main] [external/envoy/source/server/server.cc:98] error initializing configuration '/home/mssn/.getenvoy/configs/postgres.yaml': Proto constraint validation failed (BootstrapValidationError.StaticResources: ["embedded message failed validation"] | caused by StaticResourcesValidationError.Listeners[i]: ["embedded message failed validation"] | caused by ListenerValidationError.Address: ["embedded message failed validation"] | caused by AddressValidationError.SocketAddress: ["embedded message failed validation"] | caused by field: "port_specifier", reason: is required): static_resources {
  listeners {
    name: "postgres_listener"
    address {
      socket_address {
        address: "0.0.0.0"
      }
    }
  }
}

[2020-07-22 18:43:38.479][39037][info][main] [external/envoy/source/server/server.cc:645] exiting
Proto constraint validation failed (BootstrapValidationError.StaticResources: ["embedded message failed validation"] | caused by StaticResourcesValidationError.Listeners[i]: ["embedded message failed validation"] | caused by ListenerValidationError.Address: ["embedded message failed validation"] | caused by AddressValidationError.SocketAddress: ["embedded message failed validation"] | caused by field: "port_specifier", reason: is required): static_resources {
  listeners {
    name: "postgres_listener"
    address {
      socket_address {
        address: "0.0.0.0"
      }
    }
  }
}
```

it seems to be due to the broken config `.getenvoy/configs/postgres.yml`.

```
$ cat ~/.getenvoy/configs/postgres.yaml
static_resources:
  listeners:
  - name: postgres_listener
    address:
      socket_address:
        address: 0.0.0.0
        port_value: %
```
cpakulski: @musaprg, @yskopets, @lizan I addressed most comments. The remaining issues are:

1. endpoint 
- should we stick to only one endpoint in the cluster or allow for multiple
- right now it must be IP address

2. conflict with -c/--config-path parameters which may be specified by a user. Not sure that is the best approach to this.
Basically when templateArgs are specified, getenvoy creates config file and appends it to the existing cmd line parameters. We can end up with potentially conflicting settings.

yskopets: > should we stick to only one endpoint in the cluster or allow for multiple ?

My vote is for supporting multiple endpoints. A user will have to provide a `,` separated value, e.g. `host1:5432,host2:5432`

> right now it must be IP address

If I understand correctly, `LOGICAL_DNS` fixes the problem. Let's use it instead of `STATIC`.

> conflict with -c/--config-path parameters which may be specified by a user. Not sure that is the best approach to this.
Basically when templateArgs are specified, getenvoy creates config file and appends it to the existing cmd line parameters. We can end up with potentially conflicting settings.

I think, it's reasonable behaviour. Even if it might fail occasionally.
cpakulski: > > right now it must be IP address
> 
> If I understand correctly, LOGICAL_DNS fixes the problem. Let's use it instead of STATIC.

The difference between LOGICAL and STRICT DNS is that in STRICT mode, DNS server returns set of IP addresses and Envoy should distribute load among them. LOGICAL DNS has kind of load balancing implemented and each time Envoy ask it returns different IP address, so load balancing is implemented in DNS, not Envoy. But AFAIK it still requires domain name, not endpoint's IP.

It will complicate the logic, but I will implement it as follows:
- if endpoints are IP addresses, use STATIC and create multiple endpoints
- if endpoint is name, use STRICT_DNS and verify that only one endpoint is specified.

lizan: > > > right now it must be IP address
> > 
> > 
> > If I understand correctly, LOGICAL_DNS fixes the problem. Let's use it instead of STATIC.
> 
> The difference between LOGICAL and STRICT DNS is that in STRICT mode, DNS server returns set of IP addresses and Envoy should distribute load among them. LOGICAL DNS has kind of load balancing implemented and each time Envoy ask it returns different IP address, so load balancing is implemented in DNS, not Envoy. But AFAIK it still requires domain name, not endpoint's IP.
> 
> It will complicate the logic, but I will implement it as follows:
> 
> * if endpoints are IP addresses, use STATIC and create multiple endpoints
> * if endpoint is name, use STRICT_DNS and verify that only one endpoint is specified.

For now I think we can do STRICT_DNS with only one endpoint, we can make this improvement as a follow up. As this is targeted for local devs.
cpakulski: Reworked logic in flavor processing.

Multiple endpoints may be specified, but they must be the same type. 

If all endpoints are IP addresses:
`getevoy run postgres:nightly --templateArg endpoint=127.0.0.1:1234,128.0.0.1:1234`
the STATIC cluster will be created.

If all endpoints are hostnames:
`getevoy run postgres:nightly --templateArg endpoint=postgres1:1234,postgres2:1234`
strict_dns cluster will be created.

Mixing types will result in error:
`getevoy run postgres:nightly --templateArg endpoint=127.0.0.1:1234,postgres:1234`

cpakulski: I think I addressed all comments. The remaining issues are:

- input params: I named them `endpoint` and `inport`. @yskopets suggested `ingress.port`. I like the naming, but the dot suggests that there is multi-level set of params and port is just one of them.

- config file is still saved in .getenvoy/configs directory. It is just overwritten each time getenvoy is run. I personally like the fact that I can copy this file out and use it as starting point for more complex config. Using temporary file would not allow for that.
cpakulski: Removed code checking if a user also specified config file. If it is the case, Envoy will complain with `-c (--config-path) -- Argument already set!` message.

Patch:
diff --git a/go.mod b/go.mod
index 53eef20..39910c2 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.12
 require (
 	bitbucket.org/creachadair/shell v0.0.6
 	github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
+	github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
 	github.com/go-ole/go-ole v1.2.4 // indirect
 	github.com/gogo/protobuf v1.2.2-0.20190730201129-28a6bbf47e48
 	github.com/golang/protobuf v1.3.2
diff --git a/go.sum b/go.sum
index 36b32b7..6c13d5c 100644
--- a/go.sum
+++ b/go.sum
@@ -50,6 +50,7 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
 github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM=
 github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg=
 github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
 github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
 github.com/aws/aws-sdk-go v1.13.24/go.mod h1:ZRmQr0FajVIyZ4ZzBYKG5P3ZqPz9IHG41ZoMu1ADI3k=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
diff --git a/pkg/binary/envoy/config.go b/pkg/binary/envoy/config.go
index 0a99cb8..09b6c94 100644
--- a/pkg/binary/envoy/config.go
+++ b/pkg/binary/envoy/config.go
@@ -15,6 +15,10 @@
 package envoy
 
 import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
 	"time"
 
 	"github.com/gogo/protobuf/types"
@@ -70,3 +74,18 @@ type Config struct {
 	AdminPort      int32
 	StatNameLength int32
 }
+
+// SaveConfig saves configuration string in getenvoy
+// directory.
+func (r *Runtime) SaveConfig(name, config string) (string, error) {
+	configDir := filepath.Join(r.RootDir, "configs")
+	if err := os.MkdirAll(configDir, 0750); err != nil {
+		return "", fmt.Errorf("Unable to create directory %q: %v", configDir, err)
+	}
+	filename := name + ".yaml"
+	err := ioutil.WriteFile(filepath.Join(configDir, filename), []byte(config), 0644)
+	if err != nil {
+		return "", fmt.Errorf("Cannot save config file %s: %s", filepath.Join(configDir, filename), err)
+	}
+	return filepath.Join(configDir, filename), nil
+}
diff --git a/pkg/binary/envoy/runtime.go b/pkg/binary/envoy/runtime.go
index 2e19c80..eb9c5e1 100644
--- a/pkg/binary/envoy/runtime.go
+++ b/pkg/binary/envoy/runtime.go
@@ -34,6 +34,7 @@ func NewRuntime(options ...func(*Runtime)) (binary.FetchRunner, error) {
 	local := filepath.Join(usrDir, ".getenvoy")
 	runtime := &Runtime{
 		Config:         NewConfig(),
+		RootDir:        local,
 		fetcher:        fetcher{local},
 		TmplDir:        filepath.Join(local, "templates"),
 		wg:             &sync.WaitGroup{},
@@ -60,6 +61,7 @@ type fetcher struct {
 type Runtime struct {
 	fetcher
 
+	RootDir  string
 	debugDir string
 	TmplDir  string
 	Config   *Config
diff --git a/pkg/binary/interface.go b/pkg/binary/interface.go
index fb05e55..082f954 100644
--- a/pkg/binary/interface.go
+++ b/pkg/binary/interface.go
@@ -39,6 +39,10 @@ type Runner interface {
 	DebugStore() string
 	SetStdout(w io.Writer)
 	SetStderr(w io.Writer)
+	// TODO(cpakulski) Maybe Runner is not the best place for SaveConfig.
+	// It was added here because it needs location of .getenvoy
+	// directory.
+	SaveConfig(name string, config string) (string, error)
 }
 
 const (
diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go
index 02a8b4b..07953b7 100644
--- a/pkg/cmd/run.go
+++ b/pkg/cmd/run.go
@@ -24,6 +24,8 @@ import (
 	"github.com/tetratelabs/getenvoy/pkg/binary/envoy"
 	"github.com/tetratelabs/getenvoy/pkg/binary/envoy/controlplane"
 	"github.com/tetratelabs/getenvoy/pkg/binary/envoy/debug"
+	"github.com/tetratelabs/getenvoy/pkg/flavors"
+	_ "github.com/tetratelabs/getenvoy/pkg/flavors/postgres" //nolint
 	"github.com/tetratelabs/getenvoy/pkg/manifest"
 )
 
@@ -32,6 +34,7 @@ var (
 	accessLogServerAddress string
 	mode                   string
 	bootstrap              string
+	templateArgs           map[string]string
 )
 
 // NewRunCmd create a command responsible for starting an Envoy process
@@ -53,18 +56,13 @@ getenvoy run ./envoy -- --config-path ./bootstrap.yaml
 
 # List available Envoy flags.
 getenvoy run standard:1.11.1 -- --help
+
+# Run with Postgres specific configuration bootstrapped
+getenvoy run postgres:nightly --templateArg endpoints=127.0.0.1:5432,192.168.0.101:5432 --templateArg inport=5555
 `,
 		Args: func(cmd *cobra.Command, args []string) error {
-			if len(args) == 0 {
-				return errors.New("missing binary parameter")
-			}
-			if err := validateMode(); err != nil {
-				return err
-			}
-			if err := validateBootstrap(); err != nil {
-				return err
-			}
-			return validateRequiresBootstrap()
+			return validateCmdArgs(args)
+
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg := envoy.NewConfig(
@@ -88,12 +86,25 @@ getenvoy run standard:1.11.1 -- --help
 			}
 
 			key, manifestErr := manifest.NewKey(args[0])
+
 			if manifestErr != nil {
 				if _, err := os.Stat(args[0]); err != nil {
 					return fmt.Errorf("%v isn't valid manifest reference or an existing filepath", args[0])
 				}
 				return runtime.RunPath(args[0], args[1:])
 			}
+
+			// Check if the templateArgs were passed to the cmd line.
+			// If they were passed, config must be created based on
+			// template.
+			if len(templateArgs) > 0 {
+				cmdArg, err := processTemplateArgs(key.Flavor, templateArgs, runtime.(*envoy.Runtime))
+				if err != nil {
+					return err
+				}
+				args = append(args, cmdArg)
+			}
+
 			if !runtime.AlreadyDownloaded(key) {
 				location, err := manifest.Locate(key, manifestURL)
 				if err != nil {
@@ -114,6 +125,8 @@ getenvoy run standard:1.11.1 -- --help
 		"(experimental) location of Envoy's access log server <host|ip:port> (requires bootstrap flag)")
 	cmd.Flags().StringVar(&mode, "mode", "",
 		fmt.Sprintf("(experimental) mode to run Envoy in <%v> (requires bootstrap flag)", strings.Join(envoy.SupportedModes, "|")))
+	cmd.Flags().StringToStringVar(&templateArgs, "templateArg", map[string]string{},
+		"arguments passed to a config template for substitution")
 	return cmd
 }
 
@@ -153,6 +166,19 @@ func validateRequiresBootstrap() error {
 	return nil
 }
 
+func validateCmdArgs(args []string) error {
+	if len(args) == 0 {
+		return errors.New("missing binary parameter")
+	}
+	if err := validateMode(); err != nil {
+		return err
+	}
+	if err := validateBootstrap(); err != nil {
+		return err
+	}
+	return validateRequiresBootstrap()
+}
+
 func controlplaneFunc() func(r *envoy.Runtime) {
 	switch bootstrap {
 	case istio:
@@ -162,3 +188,18 @@ func controlplaneFunc() func(r *envoy.Runtime) {
 		return func(r *envoy.Runtime) {}
 	}
 }
+
+// Function creates config file based on template args passed by a user.
+// The return value is Envoy command line option which must be passed to Envoy.
+func processTemplateArgs(flavor string, templateArgs map[string]string, runtime *envoy.Runtime) (string, error) {
+	config, err := flavors.CreateConfig(flavor, templateArgs)
+	if err != nil {
+		return "", err
+	}
+	// Save config in getenvoy directory
+	path, err := runtime.SaveConfig(flavor, config)
+	if err != nil {
+		return "", err
+	}
+	return "--config-path " + path, nil
+}
diff --git a/pkg/flavors/flavors.go b/pkg/flavors/flavors.go
new file mode 100644
index 0000000..38099df
--- /dev/null
+++ b/pkg/flavors/flavors.go
@@ -0,0 +1,62 @@
+// Copyright 2020 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package flavors
+
+import (
+	"fmt"
+)
+
+// FlavorConfigTemplate represents interface to individual flavors.
+type FlavorConfigTemplate interface {
+	GenerateConfig(params map[string]string) (string, error)
+}
+
+// Main repo for templates.
+type flavorStore struct {
+	// This is a map indexed by flavor pointing to the individual
+	// implementaions of each flavor.
+	templates map[string]FlavorConfigTemplate
+}
+
+var store = flavorStore{templates: make(map[string]FlavorConfigTemplate)}
+
+// AddFlavor function is used by individual flavors (like postgres)
+// to add the flavor to main repo.
+func AddFlavor(flavor string, configTemplate FlavorConfigTemplate) {
+	store.templates[flavor] = configTemplate
+}
+
+// GetFlavor function returns FlavorConfigTemplate structure associated
+// with flavor.
+func GetFlavor(flavor string) (FlavorConfigTemplate, error) {
+	tmplString, ok := store.templates[flavor]
+	if !ok {
+		return nil, fmt.Errorf("Cannot find template for flavor %s", flavor)
+	}
+
+	return tmplString, nil
+}
+
+// CreateConfig function checks flavor specific parameters, get flavor's template and
+// create a config.
+func CreateConfig(flavor string, params map[string]string) (string, error) {
+	flavorData, err := GetFlavor(flavor)
+
+	if err != nil {
+		return "", err
+	}
+
+	return flavorData.GenerateConfig(params)
+}
diff --git a/pkg/flavors/postgres/postgres.go b/pkg/flavors/postgres/postgres.go
new file mode 100644
index 0000000..34ad677
--- /dev/null
+++ b/pkg/flavors/postgres/postgres.go
@@ -0,0 +1,265 @@
+// Copyright 2020 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package postgres
+
+import (
+	"bytes"
+	"fmt"
+	"net"
+	"strings"
+	"text/template"
+
+	valid "github.com/asaskevich/govalidator"
+	"github.com/tetratelabs/getenvoy/pkg/flavors"
+	"github.com/tetratelabs/log"
+)
+
+// Define template parameter names
+const endpointsParam string = "endpoints"
+const inportParam string = "inport"
+
+// Flavor implements flavor.FlavorConfigTemplate interface
+// and stores config data specific to Postgres template.
+type Flavor struct {
+	// Location of the postgres server
+	endpoints []*clusterEndpoint
+	// Envoy's listener port
+	InPort      string
+	ClusterType string
+}
+
+type clusterEndpoint struct {
+	EndpointAddr string
+	EndpointPort string
+	IsIP         bool
+}
+
+var flavor = Flavor{
+	InPort: "5432",
+}
+
+func init() {
+	// Register postgres flavor.
+	flavors.AddFlavor("postgres", &flavor)
+}
+
+// GenerateConfig method takes command line parameters and creates
+// Postgres specific Envoy config.
+func (f *Flavor) GenerateConfig(params map[string]string) (string, error) {
+	var err error
+	var config string
+
+	err = f.parseInputParams(params)
+	if err != nil {
+		return "", err
+	}
+	// Now process the main template. This includes everything except endpoints.
+	// NOw run the template substitution
+	mainConfig, err := f.generateMainConfig()
+	if err != nil {
+		return "", err
+	}
+	config += mainConfig
+
+	endpointsConfig, err := f.generateEndpointSetConfig()
+	if err != nil {
+		return "", err
+	}
+	config += endpointsConfig
+
+	return config, nil
+}
+
+// Utility functions.
+
+// Function parses and verifies inout params for consistency
+// and correctness.
+func (f *Flavor) parseInputParams(params map[string]string) error {
+	var err error
+
+	for param, value := range params {
+		switch param {
+		case endpointsParam:
+			f.endpoints, err = parseEndpointSet(value)
+			if err != nil {
+				return err
+			}
+		case inportParam:
+			if !valid.IsInt(value) {
+				return fmt.Errorf("Value for templateArg %s must be integer number", param)
+			}
+			f.InPort = value
+		default:
+			log.Warnf("Ignoring unrecognized template parameter: %s", param)
+		}
+	}
+
+	// Check if all required params have been found in the parameter list
+	if len(f.endpoints) == 0 {
+		return fmt.Errorf("Parameter endpoints must be specified")
+	}
+
+	// Verify that all endpoints are of the same type:
+	// All are IP address or all are domain name.
+	clusterType := f.endpoints[0].IsIP
+	for _, singleEndpoint := range f.endpoints[1:] {
+		if singleEndpoint.IsIP != clusterType {
+			return fmt.Errorf("Endpoints must be of the same type type: IP addresses or hostnames")
+		}
+	}
+	return nil
+}
+
+// Endpoint may have the following forms:
+// IP address: like 127.0.0.1
+// IP address and port: 127.0.0.1:5432
+// domain name: postgres
+// domain name and port: postgres:5432
+func parseSingleEndpoint(endpoint string) (*clusterEndpoint, error) {
+	var host, port string
+	var err error
+	parts := strings.Split(endpoint, ":")
+	if len(parts) > 2 {
+		return nil, fmt.Errorf("%s endpoint has incorrect format. Should be endpoint[:port]", endpoint)
+	}
+
+	if len(parts) == 2 {
+		host, port, err = net.SplitHostPort(endpoint)
+		if err != nil {
+			return nil, fmt.Errorf("%s endpoint has incorrect format. Should be endpoint[:port]", endpoint)
+		}
+		if !valid.IsInt(port) {
+			return nil, fmt.Errorf("Port value in endpoint:port must be integer not %s", port)
+		}
+	} else {
+		host = parts[0]
+		port = "5432"
+	}
+
+	// This is just IP address or domain name.
+	isIP := net.ParseIP(host) != nil
+
+	return &clusterEndpoint{
+		EndpointAddr: host,
+		EndpointPort: port,
+		IsIP:         isIP}, nil
+
+}
+
+// Function parses a string of comma separated endpoints.
+func parseEndpointSet(endpoints string) ([]*clusterEndpoint, error) {
+	// endpoints is comma separated list of endpoints
+	singleEndpoints := strings.Split(endpoints, ",")
+
+	clusterEndpoints := make([]*clusterEndpoint, 0, len(singleEndpoints))
+
+	for _, endpoint := range singleEndpoints {
+		singleClusterEndpoint, err := parseSingleEndpoint(endpoint)
+		if err != nil {
+			return nil, err
+		}
+		clusterEndpoints = append(clusterEndpoints, singleClusterEndpoint)
+	}
+
+	return clusterEndpoints, nil
+}
+
+func (f *Flavor) generateEndpointSetConfig() (string, error) {
+	var buf bytes.Buffer
+
+	tmpl := template.New("postgres-endpoint")
+	tmpl, err := tmpl.Parse(clusterEndpointTemplate)
+	if err != nil {
+		// Template is not supplied by a user, but is compiled-in, so this error should
+		// happen only during development time.
+		return "", fmt.Errorf("Cannot parse postgres endpoint template")
+	}
+
+	for _, singleEndpoint := range f.endpoints {
+		err = tmpl.Execute(&buf, singleEndpoint)
+		if err != nil {
+			return "", fmt.Errorf("Cannot execute postgres endpoint template")
+		}
+	}
+
+	return buf.String(), nil
+}
+
+func (f *Flavor) generateMainConfig() (string, error) {
+	var buf bytes.Buffer
+
+	if f.endpoints[0].IsIP {
+		f.ClusterType = "static"
+	} else {
+		f.ClusterType = "strict_dns"
+	}
+	tmpl := template.New("postgres-main")
+	tmpl, err := tmpl.Parse(configTemplate)
+	if err != nil {
+		// Template is not supplied by a user, but is compiled-in, so this error should
+		// happen only during development time.
+		return "", fmt.Errorf("Cannot parse postgres main template")
+	}
+	err = tmpl.Execute(&buf, f)
+	if err != nil {
+		return "", fmt.Errorf("Cannot execute postgres main template")
+	}
+
+	return buf.String(), nil
+}
+
+// Postgres specific config file.
+var configTemplate = `
+admin:
+  access_log_path: "/dev/null"
+  address:
+    socket_address:
+      address: 0.0.0.0
+      port_value: 15000
+
+static_resources:
+  listeners:
+  - name: postgres_listener
+    address:
+      socket_address:
+        address: 0.0.0.0
+        port_value: {{ .InPort }}
+    filter_chains:
+    - filters:
+      - name: envoy.filters.network.postgres_proxy
+        typed_config:
+          "@type": type.googleapis.com/envoy.extensions.filters.network.postgres_proxy.v3alpha.PostgresProxy
+          stat_prefix: egress_postgres
+      - name: envoy.tcp_proxy
+        typed_config:
+          "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
+          stat_prefix: postgres_tcp
+          cluster: postgres_cluster
+
+  clusters:
+  - name: postgres_cluster
+    connect_timeout: 1s
+    type: {{ .ClusterType }}
+    load_assignment:
+      cluster_name: postgres_cluster
+      endpoints:
+      - lb_endpoints:`
+
+var clusterEndpointTemplate = `
+        - endpoint:
+            address:
+              socket_address:
+                address: {{ .EndpointAddr }} 
+                port_value: {{ .EndpointPort }}`


Test patch:
diff --git a/pkg/flavors/flavors_test.go b/pkg/flavors/flavors_test.go
new file mode 100644
index 0000000..958ee19
--- /dev/null
+++ b/pkg/flavors/flavors_test.go
@@ -0,0 +1,76 @@
+// Copyright 2020 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package flavors_test
+
+import (
+	"testing"
+
+	"github.com/tetratelabs/getenvoy/pkg/flavors"
+)
+
+// Flavor for mocking and testing.
+type TestFlavor struct {
+	Test string
+}
+
+var flavor TestFlavor
+
+const testConfig string = "This is UnitTest config"
+
+func (f *TestFlavor) GenerateConfig(params map[string]string) (string, error) {
+	// Just return predefined value.
+	// Normally a flavor would use template but
+	// here using templates is not part of the test.
+	return testConfig, nil
+}
+
+// Test adding and retrieving config template
+func TestAdd(t *testing.T) {
+	flavors.AddFlavor("test", &flavor)
+
+	_, err := flavors.GetFlavor("test")
+
+	if err != nil {
+		t.Error("Just added template cannot be located")
+	}
+}
+
+// Test retrieving non-existing template
+func TestGetNonExisting(t *testing.T) {
+	flavors.AddFlavor("test", &flavor)
+
+	_, err := flavors.GetFlavor("test1")
+
+	if err == nil {
+		t.Error("Error should be returned for non-existing template")
+	}
+}
+
+// Test creating config.
+// Test verifies that after adding TestFlavor to the list
+// of known flavors, it can create a proper config.
+func TestCreateConfig(t *testing.T) {
+	flavors.AddFlavor("test", &flavor)
+
+	params := map[string]string{"Test": "UnitTest"}
+	config, err := flavors.CreateConfig("test", params)
+
+	if err != nil {
+		t.Error("Creating config failed with proper parameters")
+	}
+
+	if config != testConfig {
+		t.Errorf("Created config %s not as expected", config)
+	}
+}
diff --git a/pkg/flavors/postgres/postgres_test.go b/pkg/flavors/postgres/postgres_test.go
new file mode 100644
index 0000000..9d8f415
--- /dev/null
+++ b/pkg/flavors/postgres/postgres_test.go
@@ -0,0 +1,212 @@
+// Copyright 2020 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package postgres
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/tetratelabs/getenvoy/pkg/flavors"
+)
+
+// Test verifies that postgres flavor is registered when module is loaded.
+func TestInit(t *testing.T) {
+	_, err := flavors.GetFlavor("postgres")
+
+	if err != nil {
+		t.Error("postgres flavor should be registered at init phase.")
+	}
+}
+
+type testInputEndpoint struct {
+	endpoints   string
+	parseResult clusterEndpoint
+	result      bool
+}
+
+// Test parsing a single endpoint, which is in one of the following forms:
+// IP:port
+// IP
+// name:Port
+// name
+//
+// If port is not specified in the endpoint it is assumed to be 5432
+func TestParseSingleEndpoint(t *testing.T) {
+	input := []testInputEndpoint{
+		{"127.0.0.1:12:34", clusterEndpoint{}, false},
+		{"127.0.0.1:blah", clusterEndpoint{}, false},
+		{"127.0.0.1:12", clusterEndpoint{"127.0.0.1", "12", true}, true},
+		{"127.0.0.1", clusterEndpoint{"127.0.0.1", "5432", true}, true},
+		{"127.0", clusterEndpoint{"127.0", "5432", false}, true},
+		{"127.0.0.0.1", clusterEndpoint{"127.0.0.0.1", "5432", false}, true},
+		{"127.0.", clusterEndpoint{"127.0.", "5432", false}, true},
+		{"postgres.com:34:12", clusterEndpoint{}, false},
+		{"postgres.com:12", clusterEndpoint{"postgres.com", "12", false}, true},
+		{"postgres", clusterEndpoint{"postgres", "5432", false}, true},
+	}
+
+	for _, testCase := range input {
+		parsed, err := parseSingleEndpoint(testCase.endpoints)
+		if testCase.result != (err == nil) {
+			t.Errorf("Parsing result for %s not as expected", testCase.endpoints)
+		}
+		if err != nil {
+			continue
+		}
+		if *parsed != testCase.parseResult {
+			t.Errorf("Parsed structure %v for %s is not as expected", *parsed, testCase.endpoints)
+		}
+	}
+}
+
+// Structure is used for parameterized test
+// testing parsing comma delimited list of single endpoints.
+type testInputEndpointSet struct {
+	// entry value - list of endpoints
+	endpointset string
+	// Expected parsing result. True: success.
+	result bool
+}
+
+// Endpoints are passed from command line as comma delimited string of individual endpoints.
+// This test verifies that the string is tokenized and parsed correctly.
+func TestParseEndpointSet(t *testing.T) {
+	input := []testInputEndpointSet{
+		{"127.0.0.1:3456", true},
+		{"127.0.0.1:blah", false},
+		{"127.0.0.1:3456, 127.0.0.1:5555", true},
+		{"127.0.0.1:3456127.0.0.1:5555", false},
+		{"127.0:5555", true},
+		{"postgres,127.0.0.1:3456,127.0.0.1:5555", true},
+		{"postgres:3456127.0.0.1:5555", false},
+		{"postgres:3456,postgres:5555", true},
+		{"127.0:3456,127.0.0.1.1.25555", true},
+		// IP address and host name should not be mixed
+		{"127.0.0.1:3456,postgres:2555", true},
+	}
+
+	for _, testCase := range input {
+		_, err := parseEndpointSet(testCase.endpointset)
+
+		if testCase.result != (err == nil) {
+			t.Errorf("Parsing result of endpointset %s no as expected: %s", testCase.endpointset, err)
+		}
+	}
+}
+
+// Structure is used for parameterized testing of input params parsing
+// end verification
+type testInputCmdParams struct {
+	params map[string]string
+	result bool
+}
+
+// Test verifies that parsing input parameters should fail
+// when endpoint is not specified and when specified port has wrong format.
+func TestInputCmdParams(t *testing.T) {
+	input := []testInputCmdParams{
+		{map[string]string{"endpoints": "127.0.0.1:3456"}, true},
+		{map[string]string{"endpoints1": "127.0.0.1:3456"}, false},
+		{map[string]string{"endpoints1": "127.0.0.1:3456", "endpoints": "128.0.0.1"}, true},
+		{map[string]string{"endpoints1": "127.0.0.1:3456", "inport": "128.0.0.1"}, false},
+		{map[string]string{"endpoints": "127.0.0.1:3456", "inport": "128.0.0.1"}, false},
+		{map[string]string{"endpoints": "127.0.0.1:3456", "inport": "5432"}, true},
+		{map[string]string{"endpoints": "127.0.0.1:blah", "inport": "5432"}, false},
+		{map[string]string{"endpoints": "127.0.0.1:3456,postgres:1234"}, false},
+	}
+
+	for _, testCase := range input {
+		var testFlavor Flavor
+		err := testFlavor.parseInputParams(testCase.params)
+
+		if testCase.result != (err == nil) {
+			t.Errorf("Parsing input params %v not as expected", testCase.params)
+		}
+	}
+}
+
+// Structure is used for parameterized testing of creating
+// endpoints part of Envoy config
+type testEndpointSetConfig struct {
+	// Input command line params
+	params map[string]string
+	// What must be found after processing template
+	output []string
+}
+
+// Test creating set of endpoint.
+// Test only verifies that template substitution happens.
+// Syntax and yaml formatting is not checked.
+func TestCreateEndpointsConfig(t *testing.T) {
+	var testFlavor Flavor
+
+	input := []testEndpointSetConfig{
+		{map[string]string{"endpoints": "127.0.0.1:3456"}, []string{"127.0.0.1", "3456"}},
+		{map[string]string{"endpoints": "127.0.0.1:3456,128.0.0.1"}, []string{"127.0.0.1", "3456", "128.0.0.1", "5432"}},
+		{map[string]string{"endpoints": "postgres:3456"}, []string{"postgres", "3456"}},
+		{map[string]string{"endpoints": "postgres1:3456,postgres2"}, []string{"postgres1", "3456", "postgres2", "5432"}},
+	}
+
+	for index, testCase := range input {
+		testFlavor.endpoints = testFlavor.endpoints[:0]
+		testFlavor.parseInputParams(testCase.params)
+
+		endpointsConfig, err := testFlavor.generateEndpointSetConfig()
+
+		if err != nil {
+			t.Errorf("Error creating config for testcase %d: %s", index, err)
+			continue
+		}
+
+		// Scan created config for input params
+		for _, item := range testCase.output {
+			if !strings.Contains(endpointsConfig, item) {
+				t.Errorf("Created config %s\n does not contain %s", endpointsConfig, item)
+			}
+		}
+	}
+}
+
+// Test verifies that correct cluster is created based on passed endpoint types
+// All IP addresses will create STATIC cluster, all hostnames will create STRICT DNS
+// cluster.
+func TestCreateMainConfig(t *testing.T) {
+	var testFlavor Flavor
+
+	input := []testEndpointSetConfig{
+		{map[string]string{"endpoints": "127.0.0.1:3456"}, []string{"static"}},
+		{map[string]string{"endpoints": "127.0.0.1:3456,128.0.0.1"}, []string{"static"}},
+		{map[string]string{"endpoints": "postgres:3456"}, []string{"strict_dns"}},
+		{map[string]string{"endpoints": "postgres1:3456,postgres2"}, []string{"strict_dns"}},
+	}
+
+	for index, testCase := range input {
+		testFlavor.endpoints = testFlavor.endpoints[:0]
+		testFlavor.parseInputParams(testCase.params)
+
+		endpointsConfig, err := testFlavor.generateMainConfig()
+
+		if err != nil {
+			t.Errorf("Error creating config for testcase %d: %s", index, err)
+			continue
+		}
+
+		// Scan created config for input params
+		for _, item := range testCase.output {
+			if !strings.Contains(endpointsConfig, item) {
+				t.Errorf("Created config %s\n does not contain %s", endpointsConfig, item)
+			}
+		}
+	}
+}