Răsfoiți Sursa

Merge pull request #2561 from porter-dev/nafees/app-canonical-name

[POR-808] Releases should have a canonical name
Porter Support 3 ani în urmă
părinte
comite
443cac7583

+ 17 - 1
api/server/handlers/namespace/list_releases.go

@@ -56,7 +56,23 @@ func (c *ListReleasesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	var res types.ListReleasesResponse = releases
+	var res types.ListReleasesResponse
+
+	for _, helmRel := range releases {
+		rel, err := c.Repo().Release().ReadRelease(cluster.ID, helmRel.Name, helmRel.Namespace)
+
+		if err == nil {
+			res = append(res, &types.Release{
+				Release:       helmRel,
+				PorterRelease: rel.ToReleaseType(),
+			})
+		} else {
+			res = append(res, &types.Release{
+				Release:       helmRel,
+				PorterRelease: &types.PorterRelease{},
+			})
+		}
+	}
 
 	c.WriteResult(w, r, res)
 }

+ 78 - 0
api/server/handlers/release/update_canonical_name.go

@@ -0,0 +1,78 @@
+package release
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+	"k8s.io/apimachinery/pkg/util/validation"
+)
+
+type UpdateCanonicalNameHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateCanonicalNameHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateCanonicalNameHandler {
+	return &UpdateCanonicalNameHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateCanonicalNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateCanonicalNameRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("release %s not found", name)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if release.CanonicalName != request.CanonicalName {
+		if request.CanonicalName != "" {
+			if errStrs := validation.IsDNS1123Label(request.CanonicalName); len(errStrs) > 0 {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("invalid canonical name"), http.StatusBadRequest))
+				return
+			}
+		}
+
+		release.CanonicalName = request.CanonicalName
+
+		release, err = c.Repo().Release().UpdateRelease(release)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, release.ToReleaseType())
+}

+ 32 - 2
api/server/router/release.go

@@ -783,8 +783,7 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
-	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_tags ->
-	// release.NewGetLatestJobRunHandler
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_tags -> release.NewUpdateReleaseTagsHandler
 	updateReleaseTagsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
@@ -815,6 +814,37 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_canonical_name -> release.NewUpdateCanonicalNameHandler
+	updateCanonicalNameEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/update_canonical_name",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	updateCanonicalNameHandler := release.NewUpdateCanonicalNameHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateCanonicalNameEndpoint,
+		Handler:  updateCanonicalNameHandler,
+		Router:   r,
+	})
+
 	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/git_action_config -> release.NewUpdateGitActionConfigHandler
 	updateGitActionConfigEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 2
api/types/namespace.go

@@ -4,7 +4,6 @@ import (
 	"time"
 
 	"helm.sh/helm/v3/pkg/action"
-	"helm.sh/helm/v3/pkg/release"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -83,7 +82,7 @@ type ListReleasesRequest struct {
 }
 
 // swagger:model
-type ListReleasesResponse []*release.Release
+type ListReleasesResponse []*Release
 
 type GetConfigMapRequest struct {
 	Name string `schema:"name,required"`

+ 7 - 0
api/types/release.go

@@ -38,6 +38,9 @@ type PorterRelease struct {
 
 	// Whether this release is tied to a stack or not
 	StackID string `json:"stack_id"`
+
+	// The canonical name of this release
+	CanonicalName string `json:"canonical_name"`
 }
 
 // swagger:model
@@ -211,3 +214,7 @@ type PartialGitActionConfig struct {
 type UpdateGitActionConfigRequest struct {
 	GitActionConfig *PartialGitActionConfig `json:"git_action_config"`
 }
+
+type UpdateCanonicalNameRequest struct {
+	CanonicalName string `json:"canonical_name"`
+}

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -131,7 +131,7 @@ const Chart: React.FunctionComponent<Props> = ({
     >
       <Title>
         <IconWrapper>{renderIcon()}</IconWrapper>
-        {chart.name}
+        {chart.canonical_name === "" ? chart.name : chart.canonical_name}
         {chart?.config?.description && (
           <>
             <Dot style={{ marginLeft: "9px", color: "#ffffff88" }}>•</Dot>

+ 152 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/CanonicalName.tsx

@@ -0,0 +1,152 @@
+import React, { useContext, useMemo, useState } from "react";
+import styled from "styled-components";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import api from "shared/api";
+import Color from "color";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import { isAlphanumeric } from "shared/common";
+
+type Props = {
+  onSave: (() => void) | (() => Promise<void>);
+  release: ChartType;
+};
+
+const CanonicalName = ({ onSave, release }: Props) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [canonicalName, setCanonicalName] = useState<string>(
+    release.canonical_name
+  );
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+
+    try {
+      await api.updateCanonicalName(
+        "<token>",
+        { canonical_name: canonicalName },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: release.namespace,
+          release_name: release.name,
+        }
+      );
+      await onSave();
+      setButtonStatus("successful");
+    } catch (error) {
+      console.log(error);
+      setCurrentError(
+        "We couldn't change the canonical name. Please try again."
+      );
+      setButtonStatus("Canonical name not changed.");
+      return;
+    } finally {
+      setTimeout(() => {
+        setButtonStatus("");
+      }, 800);
+    }
+  };
+
+  const shouldDisableSave = useMemo(() => {
+    if (canonicalName !== release.canonical_name) {
+      if (canonicalName === "") {
+        return false;
+      }
+
+      return !isAlphanumeric(canonicalName) || canonicalName.length > 63;
+    }
+
+    return true;
+  }, [canonicalName]);
+
+  const saveButtonHelper = useMemo(() => {
+    if (canonicalName !== release.canonical_name) {
+      if (canonicalName !== "") {
+        if (!isAlphanumeric(canonicalName)) {
+          return "Invalid characters in the name";
+        } else if (canonicalName.length > 63) {
+          return "Name cannot exceed 63 characters";
+        }
+      }
+
+      return "Unsaved changes";
+    }
+
+    return "";
+  }, [canonicalName]);
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={canonicalName}
+        setValue={(x: string) => setCanonicalName(x)}
+        placeholder="ex: my-app"
+        isRequired={true}
+        width={"100%"}
+      />
+      <Flex
+        style={{
+          marginTop: "25px",
+        }}
+      >
+        <SaveButton
+          helper={saveButtonHelper}
+          clearPosition
+          disabled={shouldDisableSave}
+          statusPosition="right"
+          text="Save changes"
+          onClick={() => handleSave()}
+          status={buttonStatus}
+        ></SaveButton>
+      </Flex>
+      <Br />
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
+export default CanonicalName;
+
+const Flex = styled.div`
+  display: flex;
+  position: relative;
+`;
+
+const Tag = styled.div<{ color: string }>`
+  display: inline-flex;
+  color: ${(props) => Color(props.color).darken(0.4).string() || "inherit"};
+  user-select: none;
+  border: 1px solid ${(props) => Color(props.color).darken(0.4).string()};
+  border-radius: 5px;
+  padding: 4px 8px;
+  position: relative;
+  margin-bottom: 20px;
+  text-align: center;
+  align-items: center;
+  font-size: 13px;
+  background-color: ${(props) => props.color || "inherit"};
+
+  max-width: 150px;
+  min-width: 60px;
+
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+
+  > .material-icons {
+    font-size: 16px;
+    :hover {
+      cursor: pointer;
+    }
+  }
+`;

+ 14 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -655,6 +655,15 @@ const ExpandedChart: React.FC<Props> = (props) => {
     );
   };
 
+  const renderHelmReleaseName = () => {
+    return (
+      <Url>
+        <Bolded>Helm Release Name:</Bolded>
+        {currentChart.name}
+      </Url>
+    );
+  };
+
   const handleUninstallChart = async () => {
     setDeleting(true);
     setCurrentOverlay(null);
@@ -873,7 +882,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   icon={currentChart.chart.metadata.icon}
                   iconWidth="33px"
                 >
-                  {currentChart.name}
+                  {currentChart.canonical_name === ""
+                    ? currentChart.name
+                    : currentChart.canonical_name}
                   <DeploymentType currentChart={currentChart} />
                   <TagWrapper>
                     Namespace{" "}
@@ -884,6 +895,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 {currentChart.chart.metadata.name != "worker" &&
                   currentChart.chart.metadata.name != "job" &&
                   renderUrl()}
+
+                {currentChart.canonical_name !== "" && renderHelmReleaseName()}
                 <InfoWrapper>
                   {/*
                   <StatusIndicator

+ 24 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -447,6 +447,12 @@ const ExpandedJobHeader: React.FC<{
       ) : null}
 
       <InfoWrapper>
+        {chart?.canonical_name !== "" ? (
+          <Url>
+            <Bolded>Helm Release Name:</Bolded>
+            {chart?.name}
+          </Url>
+        ) : null}
         <LastDeployed>
           Run {jobs?.length} times <Dot>•</Dot>Last template update at
           {" " + readableDate(chart.info.last_deployed)}
@@ -777,3 +783,21 @@ const A = styled.a`
   margin-left: 5px;
   cursor: pointer;
 `;
+
+const Bolded = styled.div`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+`;
+
+const Url = styled.div`
+  display: block;
+  font-size: 13px;
+  user-select: all;
+  user-select: text;
+  margin-top: -5px;
+  margin-bottom: 10px;
+  display: flex;
+  color: #949eff;
+  align-items: center;
+`;

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -20,6 +20,7 @@ import { isDeployedFromGithub } from "shared/release/utils";
 import TagSelector from "./TagSelector";
 import { PORTER_IMAGE_TEMPLATES } from "shared/common";
 import DynamicLink from "components/DynamicLink";
+import CanonicalName from "./CanonicalName";
 
 type PropsType = {
   currentChart: ChartType;
@@ -243,6 +244,13 @@ const SettingsSection: React.FC<PropsType> = ({
         ) : null}
 
         <>
+          <Heading>Canonical Name</Heading>
+          <Helper>Set a canonical name for this application (lowercase letters, numbers, and "-" only)</Helper>
+          <CanonicalName
+            release={currentChart}
+            onSave={() => refreshChart()}
+          />
+
           <Heading>Redeploy Webhook</Heading>
           <Helper>
             Programmatically deploy by calling this secret webhook.

+ 17 - 0
dashboard/src/shared/api.tsx

@@ -1958,6 +1958,22 @@ const updateReleaseTags = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_tags`
 );
 
+const updateCanonicalName = baseApi<
+  {
+    canonical_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, release_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_canonical_name`
+);
+
 const getGitProviders = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/integrations/git`
@@ -2476,6 +2492,7 @@ export default {
   getTagsByProjectId,
   createTag,
   updateReleaseTags,
+  updateCanonicalName,
   getGitProviders,
   getGitlabRepos,
   getGitlabBranches,

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -56,6 +56,7 @@ export interface ChartType {
   namespace: string;
   latest_version: string;
   tags: any;
+  canonical_name: string;
 }
 
 export interface ChartTypeWithExtendedConfig extends ChartType {

+ 7 - 3
internal/models/release.go

@@ -28,13 +28,17 @@ type Release struct {
 	NotificationConfig uint
 	BuildConfig        uint
 	Tags               []*Tag `json:"tags" gorm:"many2many:release_tags"`
+
+	// A configurable canonical name of a Porter release
+	CanonicalName string
 }
 
 func (r *Release) ToReleaseType() *types.PorterRelease {
 	res := &types.PorterRelease{
-		ID:           r.ID,
-		WebhookToken: r.WebhookToken,
-		ImageRepoURI: r.ImageRepoURI,
+		ID:            r.ID,
+		WebhookToken:  r.WebhookToken,
+		ImageRepoURI:  r.ImageRepoURI,
+		CanonicalName: r.CanonicalName,
 	}
 
 	if r.GitActionConfig != nil {