Procházet zdrojové kódy

Display URLs for each web service and disallow invalid ports (#3207)

Feroze Mohideen před 2 roky
rodič
revize
52658f3a1d

+ 38 - 8
api/server/handlers/porter_app/parse.go

@@ -2,6 +2,7 @@ package porter_app
 
 import (
 	"fmt"
+	"strconv"
 	"strings"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -65,27 +66,27 @@ func parse(
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
 	}
 
-	values, err := buildStackValues(parsed, imageInfo, existingValues, opts, injectLauncher)
+	values, err := buildUmbrellaChartValues(parsed, imageInfo, existingValues, opts, injectLauncher)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 	}
 	convertedValues := convertMap(values).(map[string]interface{})
 
-	chart, err := buildStackChart(parsed, config, projectID, existingDependencies)
+	chart, err := buildUmbrellaChart(parsed, config, projectID, existingDependencies)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
 	}
 
 	// return the parsed release values for the release job chart, if they exist
-	var releaseJobValues map[string]interface{}
+	var preDeployJobValues map[string]interface{}
 	if parsed.Release != nil && parsed.Release.Run != nil {
-		releaseJobValues = buildReleaseValues(parsed.Release, parsed.Env, imageInfo, injectLauncher)
+		preDeployJobValues = buildPreDeployJobChartValues(parsed.Release, parsed.Env, imageInfo, injectLauncher)
 	}
 
-	return chart, convertedValues, releaseJobValues, nil
+	return chart, convertedValues, preDeployJobValues, nil
 }
 
-func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existingValues map[string]interface{}, opts SubdomainCreateOpts, injectLauncher bool) (map[string]interface{}, error) {
+func buildUmbrellaChartValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existingValues map[string]interface{}, opts SubdomainCreateOpts, injectLauncher bool) (map[string]interface{}, error) {
 	values := make(map[string]interface{})
 
 	if parsed.Apps == nil {
@@ -109,6 +110,11 @@ func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existi
 			}
 		}
 
+		validateErr := validateHelmValues(helm_values)
+		if validateErr != "" {
+			return nil, fmt.Errorf("error validating service \"%s\": %s", name, validateErr)
+		}
+
 		err := createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
 		if err != nil {
 			return nil, err
@@ -160,7 +166,31 @@ func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existi
 	return values, nil
 }
 
-func buildReleaseValues(release *App, env map[string]string, imageInfo types.ImageInfo, injectLauncher bool) map[string]interface{} {
+// we can add to this function up later or use an alternative
+func validateHelmValues(values map[string]interface{}) string {
+	containerMap, err := getNestedMap(values, "container")
+	if err != nil {
+		return "error checking port: misformatted values"
+	} else {
+		portVal, portExists := containerMap["port"]
+		if portExists {
+			portStr, pOK := portVal.(string)
+			if !pOK {
+				return "error checking port: no port in container"
+			}
+
+			port, err := strconv.Atoi(portStr)
+			if err != nil || port < 1024 || port > 65535 {
+				return "port must be a number between 1024 and 65535"
+			}
+		} else {
+			return "must specify port if choosing to expose service externally"
+		}
+	}
+	return ""
+}
+
+func buildPreDeployJobChartValues(release *App, env map[string]string, imageInfo types.ImageInfo, injectLauncher bool) map[string]interface{} {
 	defaultValues := getDefaultValues(release, env, "job")
 	convertedConfig := convertMap(release.Config).(map[string]interface{})
 	helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
@@ -214,7 +244,7 @@ func getDefaultValues(app *App, env map[string]string, appType string) map[strin
 	return defaultValues
 }
 
-func buildStackChart(parsed *PorterStackYAML, config *config.Config, projectID uint, existingDependencies []*chart.Dependency) (*chart.Chart, error) {
+func buildUmbrellaChart(parsed *PorterStackYAML, config *config.Config, projectID uint, existingDependencies []*chart.Dependency) (*chart.Chart, error) {
 	deps := make([]*chart.Dependency, 0)
 	for alias, app := range parsed.Apps {
 		var appType string

+ 1 - 1
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -891,7 +891,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             <>
               <Container>
                 <Text>
-                  <a href={subdomain} target="_blank">
+                  <a href={Service.prefixSubdomain(subdomain)} target="_blank">
                     {subdomain}
                   </a>
                 </Text>

+ 1 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -225,6 +225,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
     const regex = /^[a-z0-9-]{1,61}$/;
     return regex.test(name);
   };
+
   const handleAppNameChange = (name: string) => {
     setPorterApp(PorterApp.setAttribute(porterApp, "name", name));
     if (isAppNameValid(name) && Validators.applicationName(name)) {
@@ -316,7 +317,6 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       return true;
     } catch (err: any) {
       // TODO: better error handling
-      console.log(err);
       const errMessage =
         err?.response?.data?.error ??
         err?.toString() ??

+ 80 - 39
dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx

@@ -4,7 +4,7 @@ import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import Checkbox from "components/porter/Checkbox";
-import { WebService } from "./serviceTypes";
+import { Service, WebService } from "./serviceTypes";
 import AnimateHeight, { Height } from "react-animate-height";
 
 interface Props {
@@ -15,7 +15,9 @@ interface Props {
 
 const RESOURCE_HEIGHT_WITHOUT_AUTOSCALING = 373;
 const RESOURCE_HEIGHT_WITH_AUTOSCALING = 713;
-const ADVANCED_BASE_HEIGHT = 300;
+const NETWORKING_HEIGHT_WITHOUT_INGRESS = 204;
+const NETWORKING_HEIGHT_WITH_INGRESS = 353;
+const ADVANCED_BASE_HEIGHT = 215;
 const PROBE_INPUTS_HEIGHT = 230;
 
 const WebTabs: React.FC<Props> = ({
@@ -26,7 +28,7 @@ const WebTabs: React.FC<Props> = ({
   const [currentTab, setCurrentTab] = React.useState<string>("main");
 
   const renderMain = () => {
-    setHeight(288);
+    setHeight(159);
     return (
       <>
         <Spacer y={1} />
@@ -57,6 +59,14 @@ const WebTabs: React.FC<Props> = ({
           }}
           disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
+      </>
+    );
+  };
+
+  const renderNetworking = () => {
+    setHeight(service.ingress.enabled.value ? NETWORKING_HEIGHT_WITH_INGRESS : NETWORKING_HEIGHT_WITHOUT_INGRESS)
+    return (
+      <>
         <Spacer y={1} />
         <Input
           label="Container port"
@@ -87,14 +97,48 @@ const WebTabs: React.FC<Props> = ({
           }}
           disabledTooltip={"You may only edit this field in your porter.yaml."}
         >
-          <Text color="helper">Generate a Porter URL for external traffic</Text>
+          <Text color="helper">Expose to external traffic</Text>
         </Checkbox>
+        <AnimateHeight height={service.ingress.enabled.value ? 'auto' : 0}>
+          <Spacer y={1} />
+          <Input
+            label={
+              <>
+                <span>Custom domain</span>
+                <a
+                  href="https://docs.porter.run/standard/deploying-applications/https-and-domains/custom-domains"
+                  target="_blank"
+                >
+                  &nbsp;(?)
+                </a>
+              </>
+            }
+            placeholder="ex: my-app.my-domain.com"
+            value={service.ingress.hosts.value}
+            disabled={service.ingress.hosts.readOnly}
+            width="300px"
+            setValue={(e) => {
+              editService({
+                ...service,
+                ingress: {
+                  ...service.ingress,
+                  hosts: { readOnly: false, value: e },
+                },
+              });
+            }}
+            disabledTooltip={
+              "You may only edit this field in your porter.yaml."
+            }
+          />
+          <Spacer y={1} />
+          {getApplicationURLText()}
+        </AnimateHeight>
       </>
     );
-  };
+  }
 
   const renderResources = () => {
-    service.autoscaling.enabled.value ? setHeight(RESOURCE_HEIGHT_WITH_AUTOSCALING) : setHeight(RESOURCE_HEIGHT_WITHOUT_AUTOSCALING);
+    setHeight(service.autoscaling.enabled.value ? RESOURCE_HEIGHT_WITH_AUTOSCALING : RESOURCE_HEIGHT_WITHOUT_AUTOSCALING)
     return (
       <>
         <Spacer y={1} />
@@ -285,6 +329,7 @@ const WebTabs: React.FC<Props> = ({
     }
     return height;
   };
+
   const renderHealth = () => {
     setHeight(calculateHealthHeight());
     return (
@@ -629,42 +674,36 @@ const WebTabs: React.FC<Props> = ({
   const renderAdvanced = () => {
     return (
       <>
-        <>
-          <Spacer y={1} />
-          <Input
-            label={
-              <>
-                <span>Custom domain</span>
-                <a
-                  href="https://docs.porter.run/standard/deploying-applications/https-and-domains/custom-domains"
-                  target="_blank"
-                >
-                  &nbsp;(?)
-                </a>
-              </>
-            }
-            placeholder="ex: my-app.my-domain.com"
-            value={service.ingress.hosts.value}
-            disabled={service.ingress.hosts.readOnly}
-            width="300px"
-            setValue={(e) => {
-              editService({
-                ...service,
-                ingress: {
-                  ...service.ingress,
-                  hosts: { readOnly: false, value: e },
-                },
-              });
-            }}
-            disabledTooltip={
-              "You may only edit this field in your porter.yaml."
-            }
-          />
-          {renderHealth()}
-        </>
+        {renderHealth()}
       </>
     );
   };
+
+  const getApplicationURLText = () => {
+    if (service.ingress.hosts.value !== "") {
+      return (
+        <Text>Application URL:{" "}
+          <a href={Service.prefixSubdomain(service.ingress.hosts.value)} target="_blank">
+            {service.ingress.hosts.value}
+          </a>
+        </Text>
+      )
+    } else if (service.ingress.porterHosts.value !== "") {
+      return (
+        <Text>Application URL:{" "}
+          <a href={Service.prefixSubdomain(service.ingress.porterHosts.value)} target="_blank">
+            {service.ingress.porterHosts.value}
+          </a>
+        </Text>
+      )
+    } else {
+      return (
+        <Text color="helper">
+          Application URL: Not generated yet. If no custom domain is provided, Porter will generate a URL for you on next deploy.
+        </Text>
+      )
+    }
+  }
   return (
     <>
       <>
@@ -672,6 +711,7 @@ const WebTabs: React.FC<Props> = ({
           options={[
             { label: "Main", value: "main" },
             { label: "Resources", value: "resources" },
+            { label: "Networking", value: "networking" },
             { label: "Advanced", value: "advanced" },
           ]}
           currentTab={currentTab}
@@ -679,6 +719,7 @@ const WebTabs: React.FC<Props> = ({
         />
         {currentTab === "main" && renderMain()}
         {currentTab === "resources" && renderResources()}
+        {currentTab === "networking" && renderNetworking()}
         {currentTab === "advanced" && renderAdvanced()}
       </>
     </>

+ 23 - 14
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -135,7 +135,7 @@ const WorkerService = {
             },
             canDelete: porterJson?.apps?.[name] == null,
         }
-    }
+    },
 }
 
 export type WebService = SharedServiceParams & Omit<WorkerService, 'type'> & {
@@ -281,7 +281,7 @@ const WebService = {
                 },
             }
         }
-    }
+    },
 }
 
 export type JobService = SharedServiceParams & {
@@ -332,7 +332,7 @@ const JobService = {
             cronSchedule: ServiceField.string(values.schedule?.value ?? '', porterJson?.apps?.[name]?.config?.schedule?.value),
             canDelete: porterJson?.apps?.[name] == null,
         }
-    }
+    },
 }
 
 export type ReleaseService = SharedServiceParams & {
@@ -372,7 +372,7 @@ const ReleaseService = {
             type: 'release',
             canDelete: porterJson?.release == null,
         }
-    }
+    },
 }
 
 
@@ -485,12 +485,8 @@ export const Service = {
             return "";
         }
 
-        const prefixSubdomain = (subdomain: string) => {
-            if (subdomain.startsWith('https://') || subdomain.startsWith('http://')) {
-                return subdomain;
-            }
-            return 'https://' + subdomain;
-        }
+        let matchedWebCount = 0;
+        let matchedWebHost = "";
 
         for (const web of webServices) {
             const values = helmValues[Service.toHelmName(web)];
@@ -498,14 +494,27 @@ export const Service = {
                 continue;
             }
             if (values.ingress.custom_domain && values.ingress.hosts?.length > 0) {
-                return prefixSubdomain(values.ingress.hosts[0]);
+                matchedWebCount++;
+                matchedWebHost = values.ingress.hosts[0];
             }
             if (values.ingress.porter_hosts?.length > 0) {
-                return prefixSubdomain(values.ingress.porter_hosts[0]);
+                matchedWebCount++;
+                matchedWebHost = values.ingress.porter_hosts[0];
             }
         }
 
-        return "";
-    }
+        if (matchedWebCount > 1) {
+            return "";
+        }
+
+        return matchedWebHost;
+    },
+
+    prefixSubdomain: (subdomain: string) => {
+        if (subdomain.startsWith('https://') || subdomain.startsWith('http://')) {
+            return subdomain;
+        }
+        return 'https://' + subdomain;
+    },
 }