Installer: Implement multi network selector

Change-Id: I52227a0f0e964ac48cb378ead077fad941c3315c
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 2cc29ed..dda7f8b 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -897,6 +897,8 @@
 		return ret
 	case KindNetwork:
 		return []string{}
+	case KindMultiNetwork:
+		return []string{}
 	case KindAuth:
 		return []string{}
 	case KindSSHKey:
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 7e64d50..5051cc9 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -373,3 +373,19 @@
 		t.Log(string(r))
 	}
 }
+
+func TestDodoApp(t *testing.T) {
+	contents, err := valuesTmpls.ReadFile("values-tmpl/dodo-app.cue")
+	if err != nil {
+		t.Fatal(err)
+	}
+	app, err := NewCueEnvApp(CueAppData{
+		"base.cue":   []byte(cueBaseConfig),
+		"app.cue":    []byte(contents),
+		"global.cue": []byte(cueEnvAppGlobal),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(app.Schema())
+}
diff --git a/core/installer/derived.go b/core/installer/derived.go
index e4756be..7a5e5f3 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -92,11 +92,33 @@
 			}
 			ret[k] = a
 		case KindNetwork:
-			n, err := findNetwork(networks, v.(string)) // TODO(giolekva): validate
+			name, ok := v.(string)
+			if !ok {
+				return nil, fmt.Errorf("not a string")
+			}
+			n, err := findNetwork(networks, name)
 			if err != nil {
 				return nil, err
 			}
 			ret[k] = n
+		case KindMultiNetwork:
+			vv, ok := v.([]any)
+			if !ok {
+				return nil, fmt.Errorf("not an array")
+			}
+			picked := []Network{}
+			for _, nn := range vv {
+				name, ok := nn.(string)
+				if !ok {
+					return nil, fmt.Errorf("not a string")
+				}
+				n, err := findNetwork(networks, name)
+				if err != nil {
+					return nil, err
+				}
+				picked = append(picked, n)
+			}
+			ret[k] = picked
 		case KindAuth:
 			r, err := deriveValues(v, AuthSchema, networks)
 			if err != nil {
@@ -157,6 +179,24 @@
 				return nil, fmt.Errorf("expected network name")
 			}
 			ret[k] = name
+		case KindMultiNetwork:
+			nl, ok := v.([]any)
+			if !ok {
+				return nil, fmt.Errorf("expected map")
+			}
+			names := []string{}
+			for _, n := range nl {
+				i, ok := n.(map[string]any)
+				if !ok {
+					return nil, fmt.Errorf("expected map")
+				}
+				name, ok := i["name"]
+				if !ok {
+					return nil, fmt.Errorf("expected network name")
+				}
+				names = append(names, name.(string))
+			}
+			ret[k] = names
 		case KindAuth:
 			vm, ok := v.(map[string]any)
 			if !ok {
diff --git a/core/installer/schema.go b/core/installer/schema.go
index 943ef8a..8b49905 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -11,16 +11,17 @@
 type Kind int
 
 const (
-	KindBoolean     Kind = 0
-	KindInt              = 7
-	KindString           = 1
-	KindStruct           = 2
-	KindNetwork          = 3
-	KindAuth             = 5
-	KindSSHKey           = 6
-	KindNumber           = 4
-	KindArrayString      = 8
-	KindPort             = 9
+	KindBoolean      Kind = 0
+	KindInt               = 7
+	KindString            = 1
+	KindStruct            = 2
+	KindNetwork           = 3
+	KindMultiNetwork      = 10
+	KindAuth              = 5
+	KindSSHKey            = 6
+	KindNumber            = 4
+	KindArrayString       = 8
+	KindPort              = 9
 )
 
 type Field struct {
@@ -82,6 +83,37 @@
 	return false
 }
 
+const multiNetworkSchema = `
+#Network: {
+    name: string
+	ingressClass: string
+	certificateIssuer: string | *""
+	domain: string
+	allocatePortAddr: string
+	reservePortAddr: string
+	deallocatePortAddr: string
+}
+
+#Networks: [...#Network]
+
+value: %s
+`
+
+func isMultiNetwork(v cue.Value) bool {
+	if v.Value().IncompleteKind() != cue.ListKind {
+		return false
+	}
+	s := fmt.Sprintf(multiNetworkSchema, fmt.Sprintf("%#v", v))
+	c := cuecontext.New()
+	u := c.CompileString(s)
+	networks := u.LookupPath(cue.ParsePath("#Networks"))
+	vv := u.LookupPath(cue.ParsePath("value"))
+	if err := networks.Subsume(vv); err == nil {
+		return true
+	}
+	return false
+}
+
 const authSchema = `
 #Auth: {
     enabled: bool | false
@@ -198,6 +230,9 @@
 			return basicSchema{name, KindInt, false}, nil
 		}
 	case cue.ListKind:
+		if isMultiNetwork(v) {
+			return basicSchema{name, KindMultiNetwork, false}, nil
+		}
 		return basicSchema{name, KindArrayString, false}, nil
 	case cue.StructKind:
 		if isNetwork(v) {
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index ee96044..7e2a1a7 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -8,7 +8,7 @@
 	network: #Network @name(Network)
 	subdomain: string @name(Subdomain)
 	sshPort: int @name(SSH Port) @role(port)
-	allowedNetworks: string | *"" @name(Allowed Networks)
+	allowedNetworks: [...#Network] | *[] @name(Allowed Networks)
 	external: bool | *false @name(External)
 
 	// TODO(gio): auto generate
@@ -127,7 +127,7 @@
 			envConfig: base64.Encode(null, json.Marshal(global))
 			gitRepoPublicKey: input.ssKeys.public
 			persistentVolumeClaimName: volumes.db.name
-			allowedNetworks: input.allowedNetworks
+			allowedNetworks: strings.Join([for n in input.allowedNetworks { n.name }], ",")
 			external: input.external
 		}
 	}
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
index 2967bd0..e65c612 100644
--- a/core/installer/welcome/appmanager-tmpl/app.html
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -42,12 +42,37 @@
       <label {{ if $schema.Advanced }}hidden{{ end }}>
           {{ $schema.Name }}
 		  <select name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
-			  {{ if not $readonly }}<option disabled selected value>Available networks</option>{{ end }}
+			  {{ if not $readonly }}<option disabled selected value></option>{{ end }}
 			  {{ range $networks }}
 			  <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
 			  {{ end }}
 		  </select>
       </label>
+	{{ else if eq $schema.Kind 10 }}
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          {{ $schema.Name }}
+		  <details class="dropdown">
+			  {{ $selectedNetworks := index $data $name }}
+			  <summary id="{{ $name }}">{{ $selectedNetworks | join "," }}</summary>
+			  <ul>
+				  {{ range $networks }}
+					  {{ $networkName := .Name }}
+					  {{ $selected := false }}
+					  {{ range $selectedNetworks }}
+						  {{ if eq . $networkName }}
+							  {{ $selected = true }}
+						  {{ end }}
+				      {{ end }}
+					  <li>
+						  <label>
+							  <input type="checkbox" name="{{ $networkName }}" oninput="multiNetworkSelected('{{ $name }}', '{{ $networkName }}', this.checked)" {{ if $selected }}checked{{ end }} />
+							  {{ .Name }}
+						  </label>
+					  </li>
+				  {{ end }}
+			  </ul>
+		  </details>
+      </label>
 	{{ else if eq $schema.Kind 5 }}
 	  {{ $auth := index $data $name }}
 	  {{ $authEnabled := false }}
@@ -146,11 +171,37 @@
     config = config[items[i]];
   }
   config[items[items.length - 1]] = value;
-}
+ }
+
+ function getValue(name, value) {
+  let items = name.split(".")
+  for (let i = 0; i < items.length - 1; i++) {
+    if (!(items[i] in config)) {
+      config[items[i]] = {}
+    }
+    config = config[items[i]];
+  }
+  return config[items[items.length - 1]];
+ }
+
  function valueChanged(name, value) {
 	 setValue(name, value, config);
  }
 
+ function multiNetworkSelected(name, network, selected) {
+	 let v = getValue(name, config);
+	 if (v === undefined) {
+		 v = [];
+	 }
+	 if (selected) {
+		 v.push(network);
+	 } else {
+		 v = v.filter((n) => n != network);
+	 }
+	 setValue(name, v, config);
+	 document.getElementById(name).innerHTML = v.join(",");
+ }
+
  function disableForm() {
      document.querySelectorAll("#config-form input").forEach((i) => i.setAttribute("disabled", ""));
      document.querySelectorAll("#config-form select").forEach((i) => i.setAttribute("disabled", ""));
diff --git a/core/installer/welcome/appmanager-tmpl/base.html b/core/installer/welcome/appmanager-tmpl/base.html
index a32809f..efa4a35 100644
--- a/core/installer/welcome/appmanager-tmpl/base.html
+++ b/core/installer/welcome/appmanager-tmpl/base.html
@@ -3,7 +3,7 @@
 	<head>
 		<meta charset="utf-8" />
         <link rel="stylesheet" href="/static/pico.2.0.6.min.css">
-        <link rel="stylesheet" type="text/css" href="/static/appmanager.css?v=0.0.11">
+        <link rel="stylesheet" type="text/css" href="/static/appmanager.css?v=0.0.12">
 		<meta name="viewport" content="width=device-width, initial-scale=1" />
 	</head>
 	<body>
diff --git a/core/installer/welcome/static/appmanager.css b/core/installer/welcome/static/appmanager.css
index 5bd3dc4..26bce1a 100644
--- a/core/installer/welcome/static/appmanager.css
+++ b/core/installer/welcome/static/appmanager.css
@@ -11,6 +11,9 @@
   --pico-form-element-background-color: #d6d6d6;
   --pico-form-element-active-background-color: #d6d6d6;
   --pico-form-element-selected-background-color: #d6d6d6;
+  --pico-dropdown-background-color: #d6d6d6;
+  --pico-dropdown-border-color: #7f9f7f;
+  --pico-dropdown-hover-background-color: #7f9f7f;
   --pico-primary: #7f9f7f;
   --pico-primary-background: #7f9f7f;
   --pico-primary-hover: #d4888d;