Просмотр исходного кода

consolidate image endpoints into one endpoint (#3890)

Feroze Mohideen 2 лет назад
Родитель
Сommit
eaf606d5da

+ 107 - 0
api/server/handlers/project/images.go

@@ -0,0 +1,107 @@
+package project
+
+import (
+	"net/http"
+	"time"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ImagesHandler serves the /images endpoint
+type ImagesHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewImagesHandler returns a new ImagesHandler
+func NewImagesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ImagesHandler {
+	return &ImagesHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+// ImageArtifact is an instance of an image in an image repository
+type ImageArtifact struct {
+	Tag       string    `json:"tag"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Image is a representation of a docker image
+// To pull one of the image instances, you must use a string of format <image.uri>:<image.artifact.tag>
+type Image struct {
+	Uri       string          `json:"uri"`
+	Artifacts []ImageArtifact `json:"artifacts"`
+}
+
+// ImagesReponse is the response payload for the /images endpoint
+type ImagesReponse struct {
+	Images []Image `json:"images"`
+}
+
+// ServeHTTP handles the GET request to retrieve a list of images for a given project
+func (p *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-images")
+	defer span.End()
+
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if project.ID == 0 {
+		err := telemetry.Error(ctx, span, nil, "project id is 0")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var resp ImagesReponse
+
+	imagesReq := connect.NewRequest(&porterv1.ImagesRequest{
+		ProjectId: int64(project.ID),
+	})
+	ccpResp, err := p.Config().ClusterControlPlaneClient.Images(ctx, imagesReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp rollback porter app")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg.Images == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg images is nil")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, image := range ccpResp.Msg.Images {
+		var artifacts []ImageArtifact
+		for _, artifact := range image.Artifacts {
+			artifacts = append(artifacts, ImageArtifact{
+				Tag:       artifact.Tag,
+				UpdatedAt: artifact.UpdatedAt.AsTime().UTC(),
+			})
+		}
+		resp.Images = append(resp.Images, Image{
+			Uri:       image.Uri,
+			Artifacts: artifacts,
+		})
+	}
+
+	p.WriteResult(w, r, resp)
+}

+ 27 - 0
api/server/router/project.go

@@ -1429,5 +1429,32 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/images -> project.ImagesHandler
+	imagesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/images",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	imagesHandler := project.NewImagesHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: imagesEndpoint,
+		Handler:  imagesHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 18 - 9
dashboard/src/main/home/app-dashboard/image-settings/ImageList.tsx

@@ -9,6 +9,7 @@ import SearchBar from "components/SearchBar";
 import Link from "components/porter/Link";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
+import _ from "lodash";
 
 type Props = {
   loading: boolean;
@@ -21,18 +22,15 @@ const ImageList: React.FC<Props> = ({
   loading,
   images,
 }) => {
-  const [error, setError] = useState<boolean>(false);
   const [searchFilter, setSearchFilter] = useState<string>("");
 
   const renderImageList = () => {
     if (loading) {
       return (
         <LoadingWrapper>
-          <Loading />
+          <Loading message={"Loading all images linked to your project"}/>
         </LoadingWrapper>
       );
-    } else if (error) {
-      return <LoadingWrapper>Error loading images</LoadingWrapper>;
     } else if (images.length === 0 && !searchFilter) {
       return <LoadingWrapper>
         <Text color="helper">No linked images found.</Text>
@@ -54,9 +52,21 @@ const ImageList: React.FC<Props> = ({
           return aIndex - bIndex;
         })
       : images.sort((a, b) => {
+        const mostRecentTagA = _.maxBy(a.artifacts, (artifact) => {
+          return new Date(artifact.updated_at ?? "").getTime();
+        });
+        const mostRecentTagB = _.maxBy(b.artifacts, (artifact) => {
+          return new Date(artifact.updated_at ?? "").getTime();
+        });
+        if (!mostRecentTagA) {
+          return 1;
+        }
+        if (!mostRecentTagB) {
+          return -1;
+        }
         return (
-          new Date(b.created_at ?? "").getTime() -
-          new Date(a.created_at ?? "").getTime()
+          new Date(mostRecentTagB.updated_at ?? "").getTime() -
+          new Date(mostRecentTagA.updated_at ?? "").getTime()
         );
       });
 
@@ -80,8 +90,7 @@ const ImageList: React.FC<Props> = ({
           onClick={() => {
             setSelectedImage({
               uri: searchFilter,
-              name: searchFilter,
-              registry_id: 0,
+              artifacts: [],
             });
           }}
         >
@@ -97,7 +106,7 @@ const ImageList: React.FC<Props> = ({
     <>
       <SearchBar
         setSearchFilter={setSearchFilter}
-        disabled={error || loading}
+        disabled={loading}
         prompt={"Search images..."}
       />
       <ExpandedWrapper>

+ 24 - 103
dashboard/src/main/home/app-dashboard/image-settings/ImageSettings.tsx

@@ -8,7 +8,7 @@ import Input from "components/porter/Input";
 import { z } from "zod";
 import ImageList from "./ImageList";
 import TagList from "./TagList";
-import { ImageType } from "./types";
+import { ImageType, imageValidator } from "./types";
 
 type Props = {
     projectId: number;
@@ -29,49 +29,26 @@ const ImageSettings: React.FC<Props> = ({
 }) => {
     const [images, setImages] = useState<ImageType[]>([]);
     const [selectedImage, setSelectedImage] = useState<ImageType | undefined>(undefined);
-    const { data: registries, isLoading: isLoadingRegistries } = useQuery(
-        ["getProjectRegistries", projectId],
+    const resp = useQuery(
+        ["getImages", projectId],
         async () => {
-            const res = await api.getProjectRegistries("<token>", {}, { id: projectId });
-            return await z.array(z.object({ id: z.number() })).parseAsync(res.data);
+            const res = await api.images("<token>", {}, { project_id: projectId });
+            return await z.object({ images: z.array(imageValidator) }).parseAsync(res.data);
         },
         {
             refetchOnWindowFocus: false,
         }
-    )
-
-    const { data: imageResp, isLoading: isLoadingImages } = useQuery(
-        ["getImages", projectId, imageTag, imageUri],
-        async () => {
-            if (registries == null) {
-                return [];
-            }
-            return (await Promise.all(registries.map(async ({ id: registry_id }: { id: number }) => {
-                const res = await api.getImageRepos("<token>", {}, {
-                    project_id: projectId,
-                    registry_id,
-                });
-                const parsed = await z.array(z.object({
-                    uri: z.string(),
-                    name: z.string(),
-                })).parseAsync(res.data);
-                return parsed.map(p => ({ ...p, registry_id }))
-            }))).flat();
-        },
-        {
-            enabled: !!registries,
-            refetchOnWindowFocus: false,
-        }
     );
-
+    
     useEffect(() => {
-        if (imageResp) {
-            setImages(imageResp);
+        if (resp.isSuccess) {
+            const images = resp.data.images;
+            setImages(images);
             if (imageUri) {
-                setSelectedImage(imageResp.find((image) => image.uri === imageUri));
+                setSelectedImage(images.find((image) => image.uri === imageUri));
             }
         }
-    }, [imageResp]);
+    }, [resp]);
 
     return (
         <div>
@@ -88,14 +65,13 @@ const ImageSettings: React.FC<Props> = ({
                                 setImageUri(image.uri);
                             }}
                             images={images}
-                            loading={isLoadingImages || isLoadingRegistries}
+                            loading={resp.isLoading}
                         />
                     </ExpandedWrapper>
                     <DarkMatter antiHeight="-4px" />
                     <Spacer y={0.3} />
                 </>
             )}
-
             {imageUri && (
                 <>
                     <Input
@@ -103,7 +79,6 @@ const ImageSettings: React.FC<Props> = ({
                         label="Image URL:"
                         width="100%"
                         value={selectedImage?.uri ?? imageUri}
-                        setValue={() => { }}
                         placeholder=""
                     />
                     <BackButton
@@ -114,24 +89,7 @@ const ImageSettings: React.FC<Props> = ({
                         Select image URL
                     </BackButton>
                     <Spacer y={1} />
-                    {!imageTag && (
-                        <>
-                            <Text color="helper">Specify your image tag.</Text>
-                            <Spacer y={0.5} />
-                            <ExpandedWrapper>
-                                <TagList
-                                    selectedImage={selectedImage}
-                                    projectId={projectId}
-                                    setSelectedTag={
-                                        (tag: string) => {
-                                            setImageTag(tag);
-                                        }
-                                    }
-                                />
-                            </ExpandedWrapper>
-                        </>
-                    )}
-                    {imageTag && (
+                    {imageTag ? (
                         <>
                             <Input
                                 disabled={true}
@@ -152,6 +110,17 @@ const ImageSettings: React.FC<Props> = ({
                                 Select image tag
                             </BackButton>
                         </>
+                    ) : (
+                        <>
+                            <Text color="helper">Specify your image tag.</Text>
+                            <Spacer y={0.5} />
+                            <ExpandedWrapper>
+                                <TagList
+                                    selectedImage={selectedImage}
+                                    setSelectedTag={setImageTag}
+                                />
+                            </ExpandedWrapper>
+                        </>
                     )}
                 </>
             )}
@@ -198,51 +167,3 @@ const BackButton = styled.div`
     margin-right: 6px;
   }
 `;
-
-const StyledAdvancedBuildSettings = styled.div`
-  color: ${({ showSettings }) => (showSettings ? "white" : "#aaaabb")};
-  background: ${({ theme }) => theme.fg};
-  border: 1px solid #494b4f;
-  :hover {
-    border: 1px solid #7a7b80;
-    color: white;
-  }
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  border-radius: 5px;
-  height: 40px;
-  font-size: 13px;
-  width: 100%;
-  padding-left: 10px;
-  cursor: pointer;
-  border-bottom-left-radius: ${({ showSettings }) => showSettings && "0px"};
-  border-bottom-right-radius: ${({ showSettings }) => showSettings && "0px"};
-  .dropdown {
-    margin-right: 8px;
-    font-size: 20px;
-    cursor: pointer;
-    border-radius: 20px;
-    transform: ${(props: { showSettings: boolean; isCurrent: boolean }) =>
-        props.showSettings ? "" : "rotate(-90deg)"};
-  }
-`;
-
-const AdvancedBuildTitle = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const StyledSourceBox = styled.div`
-  width: 100%;
-  color: #ffffff;
-  padding: 25px 35px 25px;
-  position: relative;
-  font-size: 13px;
-  border-radius: 5px;
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-  border-top: 0px;
-  border-top-left-radius: 0px;
-  border-top-right-radius: 0px;
-`;

+ 30 - 61
dashboard/src/main/home/app-dashboard/image-settings/TagList.tsx

@@ -1,102 +1,72 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 import tag_icon from "assets/tag.png";
 import addCircle from "assets/add-circle.png";
-
-import api from "shared/api";
-import Loading from "components/Loading";
-import { ImageType, TagType, tagValidator } from "./types";
-import { useQuery } from "@tanstack/react-query";
+import { ArtifactType, ImageType } from "./types";
 import SearchBar from "components/SearchBar";
-import { z } from "zod";
 
 type Props = {
   selectedImage?: ImageType;
-  projectId: number;
   setSelectedTag: (x: string) => void;
 };
 
 const TagList: React.FC<Props> = ({
   selectedImage,
-  projectId,
   setSelectedTag,
 }) => {
-  const [tags, setTags] = useState<TagType[]>([]);
   const [searchFilter, setSearchFilter] = useState<string>("");
 
-  const { data: tagResp, isLoading, error } = useQuery(
-    ["getImageTags", selectedImage],
-    async () => {
-      if (!selectedImage) {
-        return;
+  const renderTagList = () => {
+    if (selectedImage == null) {
+      if (searchFilter) {
+        return (
+          <TagItem
+            onClick={() => {
+              setSelectedTag(searchFilter);
+            }}
+          >
+            <img src={addCircle} />
+            {`Use tag \"${searchFilter}\"`}
+          </TagItem>
+        );
       }
-
-      const res = await api.getImageTags(
-        "<token>",
-        {},
-        {
-          project_id: projectId,
-          registry_id: selectedImage.registry_id,
-          repo_name: selectedImage.name,
-        }
-      );
-      return z.array(tagValidator).parseAsync(res.data);
-    },
-    {
-      enabled: !!selectedImage && selectedImage.registry_id !== 0,
-      refetchOnWindowFocus: false,
+      return <LoadingWrapper>Please specify an tag.</LoadingWrapper>;
     }
-  )
-
-  useEffect(() => {
-    if (tagResp) {
-      setTags(tagResp);
-    }
-  }, [tagResp])
-
-  const renderTagList = () => {
-    if (isLoading && selectedImage && selectedImage.registry_id !== 0) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (error) {
-      return <LoadingWrapper>Error loading tags.</LoadingWrapper>;
-    } else if (tags.length === 0 && !searchFilter) {
-      return <LoadingWrapper>Please specify a tag.</LoadingWrapper>;
+    
+    if (selectedImage.artifacts.length === 0 && !searchFilter) {
+      return <LoadingWrapper>Image has no tags; please specify a different image.</LoadingWrapper>;
     }
 
-    const sortedTags = searchFilter
-      ? tags
-        .filter((tag) => tag.tag.toLowerCase().includes(searchFilter.toLowerCase()))
+    const sortedArtifacts = searchFilter
+      ? selectedImage.artifacts
+        .filter(({ tag }) => tag.toLowerCase().includes(searchFilter.toLowerCase()))
         .sort((a, b) => {
           const aIndex = a.tag.toLowerCase().indexOf(searchFilter.toLowerCase());
           const bIndex = b.tag.toLowerCase().indexOf(searchFilter.toLowerCase());
           return aIndex - bIndex;
         })
-      : tags.sort((a, b) => {
+      : selectedImage.artifacts.sort((a, b) => {
         return (
-          new Date(b.pushed_at ?? "").getTime() -
-          new Date(a.pushed_at ?? "").getTime()
+          new Date(b.updated_at ?? "").getTime() -
+          new Date(a.updated_at ?? "").getTime()
         );
       })
 
-    const tagCards = sortedTags.map((tag: TagType, i: number) => {
+    const tagCards = sortedArtifacts.map((artifact: ArtifactType, i: number) => {
       return (
         <TagItem
           key={i}
           onClick={() => {
-            setSelectedTag(tag.tag);
+            setSelectedTag(artifact.tag);
           }}
         >
           <img src={tag_icon} />
-          {tag.tag}
+          {artifact.tag}
         </TagItem>
       );
     });
 
-    if (searchFilter !== "" && !tags.some((tag) => tag.tag === searchFilter)) {
+    if (searchFilter !== "" && !sortedArtifacts.some(({ tag }) => tag === searchFilter)) {
       tagCards.push(
         <TagItem
           onClick={() => {
@@ -116,7 +86,7 @@ const TagList: React.FC<Props> = ({
     <>
       <SearchBar
         setSearchFilter={setSearchFilter}
-        disabled={error != null || isLoading}
+        disabled={false}
         prompt={"Search tags..."}
       />
       <ExpandedWrapper>
@@ -170,5 +140,4 @@ const LoadingWrapper = styled.div`
   align-items: center;
   justify-content: center;
   font-size: 13px;
-  color: #ffffff44;
 `;

+ 8 - 7
dashboard/src/main/home/app-dashboard/image-settings/types.ts

@@ -1,12 +1,13 @@
 import { z } from "zod";
 
+const artifactValidator = z.object({
+    tag: z.string(),
+    updated_at: z.string(),
+})
+export type ArtifactType = z.infer<typeof artifactValidator>;
+
 export const imageValidator = z.object({
     uri: z.string(),
-    name: z.string(),
-    created_at: z.string().optional(),
-    registry_id: z.number(),
+    artifacts: z.array(artifactValidator),
 })
-export type ImageType = z.infer<typeof imageValidator>;
-
-export const tagValidator = z.object({ tag: z.string(), pushed_at: z.string() })
-export type TagType = z.infer<typeof tagValidator>;
+export type ImageType = z.infer<typeof imageValidator>;

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

@@ -1317,6 +1317,15 @@ const getImageTags = baseApi<
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
+const images = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/images`;
+});
+
 const getInfra = baseApi<
   {
     version?: string;
@@ -3220,6 +3229,7 @@ export default {
   getGitRepos,
   getImageRepos,
   getImageTags,
+  images,
   listInfraTemplates,
   getInfraTemplate,
   getInfra,

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.29
+	github.com/porter-dev/api-contracts v0.2.30
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 0
go.sum

@@ -1522,6 +1522,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/porter-dev/api-contracts v0.2.29 h1:K5NtjgAsVgPgJLk8/rv+UOlDHWzgHvaCqvT8gMjeFVM=
 github.com/porter-dev/api-contracts v0.2.29/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.30 h1:0lavqJ9zRLeDQqfZdCp1oeVlKtAqwCVnUT/X13A2wKI=
+github.com/porter-dev/api-contracts v0.2.30/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=