ClusterManager: Implements support of remote clusters.

After this change users will be able to:
* Create cluster and add/remove servers to it
* Install apps on remote cluster
* Move already installed apps between clusters
* Apps running on server being removed will auto-migrate
  to another server from that same cluster

This is achieved by:
* Installing and running minimal version of dodo on remote cluster
* Ingress-nginx is installed automatically on new clusters
* Next to nginx we run VPN client in the same pod, so that
  default cluster can establish secure communication with it
* Multiple reverse proxies are configured to get to the
  remote cluster service from ingress installed on default cluster.

Next steps:
* Support remote clusters in dodo apps (prototype ready)
* Clean up old cluster when moving app to the new one. Currently
  old cluster keeps running app pods even though no ingress can
  reach it anymore.

Change-Id: Iffc908c93416d4126a8e1c2832eae7b659cb8044
diff --git a/core/installer/derived.go b/core/installer/derived.go
index 0351b21..6c1305c 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -6,6 +6,8 @@
 	"strings"
 )
 
+const defaultClusterName = "default"
+
 type Release struct {
 	AppInstanceId string `json:"appInstanceId"`
 	Namespace     string `json:"namespace"`
@@ -69,6 +71,7 @@
 	values any,
 	schema Schema,
 	networks []Network,
+	clusters []Cluster,
 	vpnKeyGen VPNAPIClient,
 ) (map[string]any, error) {
 	ret := make(map[string]any)
@@ -95,7 +98,9 @@
 					// TODO(gio): Improve getField
 					enabled, ok = getField(root, v).(bool)
 					if !ok {
-						return nil, fmt.Errorf("could not resolve enabled: %+v %s %+v", def.Meta(), v, root)
+						enabled = false
+						// TODO(gio): validate that enabled field exists in the schema
+						// return nil, fmt.Errorf("could not resolve enabled: %+v %s %+v", def.Meta(), v, root)
 					}
 				}
 				if !enabled {
@@ -164,20 +169,36 @@
 				picked = append(picked, n)
 			}
 			ret[k] = picked
+		case KindCluster:
+			name, ok := v.(string)
+			if !ok {
+				// TODO(gio): validate that value has cluster schema
+				ret[k] = v
+			} else {
+				c, err := findCluster(clusters, name)
+				if err != nil {
+					return nil, err
+				}
+				if c == nil {
+					delete(ret, k)
+				} else {
+					ret[k] = c
+				}
+			}
 		case KindAuth:
-			r, err := deriveValues(root, v, AuthSchema, networks, vpnKeyGen)
+			r, err := deriveValues(root, v, AuthSchema, networks, clusters, vpnKeyGen)
 			if err != nil {
 				return nil, err
 			}
 			ret[k] = r
 		case KindSSHKey:
-			r, err := deriveValues(root, v, SSHKeySchema, networks, vpnKeyGen)
+			r, err := deriveValues(root, v, SSHKeySchema, networks, clusters, vpnKeyGen)
 			if err != nil {
 				return nil, err
 			}
 			ret[k] = r
 		case KindStruct:
-			r, err := deriveValues(root, v, def, networks, vpnKeyGen)
+			r, err := deriveValues(root, v, def, networks, clusters, vpnKeyGen)
 			if err != nil {
 				return nil, err
 			}
@@ -274,6 +295,16 @@
 				return nil, err
 			}
 			ret[k] = r
+		case KindCluster:
+			vm, ok := v.(map[string]any)
+			if !ok {
+				return nil, fmt.Errorf("expected map")
+			}
+			name, ok := vm["name"]
+			if !ok {
+				return nil, fmt.Errorf("expected cluster name")
+			}
+			ret[k] = name
 		default:
 			return nil, fmt.Errorf("Should not reach!")
 		}
@@ -289,3 +320,15 @@
 	}
 	return Network{}, fmt.Errorf("Network not found: %s", name)
 }
+
+func findCluster(clusters []Cluster, name string) (*Cluster, error) {
+	if name == defaultClusterName {
+		return nil, nil
+	}
+	for _, c := range clusters {
+		if c.Name == name {
+			return &c, nil
+		}
+	}
+	return nil, fmt.Errorf("Cluster not found: %s", name)
+}