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

Merge branch 'master' into 251-cluster-ip-address-on-dashboard

Nicolas Frati 5 лет назад
Родитель
Сommit
279273f106

+ 56 - 11
cli/cmd/run.go

@@ -56,25 +56,55 @@ func init() {
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 
-	podNames, err := getPods(client, namespace, args[0])
+	podsSimple, err := getPods(client, namespace, args[0])
 
 	if err != nil {
 		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
 	}
 
 	// if length of pods is 0, throw error
-	pod := ""
+	var selectedPod podSimple
 
-	if len(podNames) == 0 {
+	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
-	} else if len(podNames) == 1 {
-		pod = podNames[0]
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
 	} else {
-		pod, err = utils.PromptSelect("Select the pod:", podNames)
+		podNames := make([]string, 0)
+
+		for _, podSimple := range podsSimple {
+			podNames = append(podNames, podSimple.Name)
+		}
+
+		selectedPodName, err := utils.PromptSelect("Select the pod:", podNames)
 
 		if err != nil {
 			return err
 		}
+
+		// find selected pod
+		for _, podSimple := range podsSimple {
+			if selectedPodName == podSimple.Name {
+				selectedPod = podSimple
+			}
+		}
+	}
+
+	var selectedContainerName string
+
+	// if the selected pod has multiple container, spawn selector
+	if len(selectedPod.ContainerNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(selectedPod.ContainerNames) == 1 {
+		selectedContainerName = selectedPod.ContainerNames[0]
+	} else {
+		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
+
+		if err != nil {
+			return err
+		}
+
+		selectedContainerName = selectedContainer
 	}
 
 	restConf, err := getRESTConfig(client)
@@ -83,7 +113,7 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}
 
-	return executeRun(restConf, namespace, pod, args[1:])
+	return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
@@ -120,7 +150,12 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 	return restConf, nil
 }
 
-func getPods(client *api.Client, namespace, releaseName string) ([]string, error) {
+type podSimple struct {
+	Name           string
+	ContainerNames []string
+}
+
+func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, error) {
 	pID := getProjectID()
 	cID := getClusterID()
 
@@ -130,16 +165,25 @@ func getPods(client *api.Client, namespace, releaseName string) ([]string, error
 		return nil, err
 	}
 
-	res := make([]string, 0)
+	res := make([]podSimple, 0)
 
 	for _, pod := range resp {
-		res = append(res, pod.ObjectMeta.Name)
+		containerNames := make([]string, 0)
+
+		for _, container := range pod.Spec.Containers {
+			containerNames = append(containerNames, container.Name)
+		}
+
+		res = append(res, podSimple{
+			Name:           pod.ObjectMeta.Name,
+			ContainerNames: containerNames,
+		})
 	}
 
 	return res, nil
 }
 
-func executeRun(config *rest.Config, namespace, name string, args []string) error {
+func executeRun(config *rest.Config, namespace, name, container string, args []string) error {
 	restClient, err := rest.RESTClientFor(config)
 
 	if err != nil {
@@ -159,6 +203,7 @@ func executeRun(config *rest.Config, namespace, name string, args []string) erro
 	req.Param("stdin", "true")
 	req.Param("stdout", "true")
 	req.Param("tty", "true")
+	req.Param("container", container)
 
 	t := term.TTY{
 		In:  os.Stdin,

+ 3 - 7
dashboard/src/components/image-selector/ImageList.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
+  disableImageSelect?: boolean;
 };
 
 type StateType = {
@@ -162,11 +163,6 @@ export default class ImageList extends Component<PropsType, StateType> {
     }
   }
 
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
 
@@ -206,8 +202,8 @@ export default class ImageList extends Component<PropsType, StateType> {
   };
 
   renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.props.clickedImage) {
+    let { setSelectedImageUrl, clickedImage, disableImageSelect } = this.props;
+    if (clickedImage && !disableImageSelect) {
       return (
         <BackButton
           width="175px"

+ 5 - 101
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -17,6 +17,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   noTagSelection?: boolean;
+  disableImageSelect?: boolean;
 };
 
 type StateType = {
@@ -36,87 +37,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     clickedImage: null as ImageType | null,
   };
 
-  // componentDidMount() {
-  //   const { currentProject, setCurrentError } = this.context;
-  //   let images = [] as ImageType[];
-  //   let errors = [] as number[];
-  //   api
-  //     .getProjectRegistries("<token>", {}, { id: currentProject.id })
-  //     .then(async (res) => {
-  //       let registries = res.data;
-  //       if (registries.length === 0) {
-  //         this.setState({ loading: false });
-  //       }
-
-  //       // Loop over connected image registries
-  //       registries.forEach(async (registry: any, i: number) => {
-  //         await new Promise((nextController: (res?: any) => void) => {
-  //           api
-  //             .getImageRepos(
-  //               "<token>",
-  //               {},
-  //               {
-  //                 project_id: currentProject.id,
-  //                 registry_id: registry.id,
-  //               }
-  //             )
-  //             .then((res) => {
-  //               res.data.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-  //               // Loop over found image repositories
-  //               let newImg = res.data.map((img: any) => {
-  //                 if (this.props.selectedImageUrl === img.uri) {
-  //                   this.setState({
-  //                     clickedImage: {
-  //                       kind: registry.service,
-  //                       source: img.uri,
-  //                       name: img.name,
-  //                       registryId: registry.id,
-  //                     },
-  //                   });
-  //                 }
-  //                 return {
-  //                   kind: registry.service,
-  //                   source: img.uri,
-  //                   name: img.name,
-  //                   registryId: registry.id,
-  //                 };
-  //               });
-  //               images.push(...newImg);
-  //               errors.push(0);
-  //             })
-  //             .catch(() => errors.push(1))
-  //             .finally(() => {
-  //               if (i == registries.length - 1) {
-  //                 let error =
-  //                   errors.reduce((a, b) => {
-  //                     return a + b;
-  //                   }) == registries.length
-  //                     ? true
-  //                     : false;
-
-  //                 this.setState({
-  //                   images,
-  //                   loading: false,
-  //                   error,
-  //                 });
-  //               }
-
-  //               nextController();
-  //             });
-  //         });
-  //       });
-  //     })
-  //     .catch((err) => {
-  //       console.log(err);
-  //       this.setState({ error: true });
-  //     });
-  // }
-
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
 
@@ -155,24 +75,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     });
   };
 
-  renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.state.clickedImage) {
-      return (
-        <BackButton
-          width="175px"
-          onClick={() => {
-            setSelectedImageUrl("");
-            this.setState({ clickedImage: null });
-          }}
-        >
-          <i className="material-icons">keyboard_backspace</i>
-          Select Image Repo
-        </BackButton>
-      );
-    }
-  };
-
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
@@ -192,6 +94,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
         <img src={icon} />
         <Input
+          disabled={this.props.disableImageSelect}
           autoFocus={true}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
@@ -233,6 +136,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
         {this.state.isExpanded ? (
           <ImageList
+            disableImageSelect={this.props.disableImageSelect}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
@@ -284,13 +188,13 @@ const BackButton = styled.div`
   }
 `;
 
-const Input = styled.input`
+const Input = styled.input<{ disabled: boolean }>`
   outline: 0;
   background: none;
   border: 0;
   font-size: 13px;
   width: calc(100% - 60px);
-  color: white;
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
 `;
 
 const ImageItem = styled.div`

+ 54 - 3
dashboard/src/components/image-selector/TagList.tsx

@@ -32,7 +32,8 @@ export default class TagList extends Component<PropsType, StateType> {
     currentTag: this.props.selectedTag,
   };
 
-  componentDidMount() {
+  refreshTagList = () => {
+    this.setState({ loading: true });
     const { currentProject } = this.context;
 
     let splits = this.props.selectedImageUrl.split("/");
@@ -55,6 +56,14 @@ export default class TagList extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
+        // Sort if timestamp is available
+        if (res.data.length > 0 && res.data[0].pushed_at) {
+          res.data.sort((a: any, b: any) => {
+            let d1 = new Date(a.pushed_at);
+            let d2 = new Date(b.pushed_at);
+            return d2.getTime() - d1.getTime();
+          });
+        }
         let tags = res.data.map((tag: any, i: number) => {
           return tag.tag;
         });
@@ -64,6 +73,10 @@ export default class TagList extends Component<PropsType, StateType> {
         console.log(err);
         this.setState({ loading: false, error: true });
       });
+  };
+
+  componentDidMount() {
+    this.refreshTagList();
   }
 
   setTag = (tag: string) => {
@@ -105,7 +118,12 @@ export default class TagList extends Component<PropsType, StateType> {
     return (
       <>
         <TagNameAlt>
-          <img src={info} /> Select Image Tag
+          <Label>
+            <img src={info} /> Select Image Tag
+          </Label>
+          <Refresh onClick={this.refreshTagList}>
+            <i className="material-icons">autorenew</i> Refresh
+          </Refresh>
         </TagNameAlt>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
       </>
@@ -115,6 +133,36 @@ export default class TagList extends Component<PropsType, StateType> {
 
 TagList.contextType = Context;
 
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const Refresh = styled.div`
+  margin-right: 10px;
+  cursor: pointer;
+  color: #949eff;
+  display: flex;
+  align-items: center;
+  font-weight: 500;
+  border-radius: 3px;
+  padding: 2px 3px;
+  padding-right: 7px;
+  > i {
+    font-size: 17px;
+    margin-right: 6px;
+  }
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const StyledTagList = styled.div`
   max-height: 175px;
   position: relative;
@@ -152,10 +200,13 @@ const TagName = styled.div`
 `;
 
 const TagNameAlt = styled(TagName)`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #ffffff55;
   cursor: default;
   :hover {
-    background: #ffffff11;
+    background: none;
     > i {
       background: none;
     }

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -308,7 +308,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         {
           ...(this.state.currentChart.config as Object),
           ...values,
-        }, { forceQuotes: true }
+        },
+        { forceQuotes: true }
       );
     }
 
@@ -420,6 +421,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       case "settings":
         return (
           <SettingsSection
+            showSource={true}
             currentChart={this.state.currentChart}
             refreshChart={() => this.refreshChart(0)}
             setShowDeleteOverlay={(x: boolean) =>
@@ -554,7 +556,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               </Title>
               <InfoWrapper>
                 <LastDeployed>
-                  Run {this.state.jobs.length} times <Dot>•</Dot>Last template update at
+                  Run {this.state.jobs.length} times <Dot>•</Dot>Last template
+                  update at
                   {" " + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
               </InfoWrapper>

+ 81 - 57
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -23,6 +23,7 @@ type PropsType = {
   currentChart: ChartType;
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
+  showSource?: boolean;
 };
 
 type StateType = {
@@ -81,36 +82,76 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       .catch(console.log);
   }
 
-  redeployWithNewImage = (img: string, tag: string) => {
-    this.setState({ saveValuesStatus: "loading" });
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    // If tag is explicitly declared, parse tag
-    let imgSplits = img.split(":");
-    let parsedTag = null;
-    if (imgSplits.length > 1) {
-      img = imgSplits[0];
-      parsedTag = imgSplits[1];
+  renderWebhookSection = () => {
+    if (!this.props.currentChart?.form?.hasSource) {
+      return;
     }
 
-    let image = {
-      image: {
-        repository: img,
-        tag: parsedTag || tag,
-      },
-    };
+    if (true || this.state.webhookToken) {
+      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
+      return (
+        <>
+          {this.props.showSource && (
+            <>
+              <Heading>Source Settings</Heading>
+              <Helper>Specify an image tag to use.</Helper>
+              <ImageSelector
+                selectedTag={this.state.selectedTag}
+                selectedImageUrl={this.state.selectedImageUrl}
+                setSelectedImageUrl={(x: string) =>
+                  this.setState({ selectedImageUrl: x })
+                }
+                setSelectedTag={(x: string) =>
+                  this.setState({ selectedTag: x })
+                }
+                forceExpanded={true}
+                disableImageSelect={true}
+              />
+              <Br />
+            </>
+          )}
+          <Heading>Redeploy Webhook</Heading>
+          <Helper>
+            Programmatically deploy by calling this secret webhook.
+          </Helper>
+          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
+            <div>{webhookText}</div>
+            <i
+              className="material-icons"
+              onMouseLeave={() => this.setState({ highlightCopyButton: false })}
+            >
+              <CopyToClipboard
+                text={webhookText}
+                onSuccess={() => this.setState({ highlightCopyButton: true })}
+              />
+              content_copy
+            </i>
+          </Webhook>
+        </>
+      );
+    }
+  };
+
+  handleSubmit = () => {
+    let { currentCluster, setCurrentError, currentProject } = this.context;
+    this.setState({ saveValuesStatus: "loading" });
+
+    console.log(this.state.selectedImageUrl);
 
     let values = {};
-    let rawValues = this.props.currentChart.config;
-    for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
+    if (this.state.selectedTag) {
+      _.set(values, "image.repository", this.state.selectedImageUrl);
+      _.set(values, "image.tag", this.state.selectedTag);
     }
 
     // Weave in preexisting values and convert to yaml
-    let valuesYaml = yaml.dump({
-      ...values,
-      ...image,
-    });
+    let conf = yaml.dump(
+      {
+        ...(this.props.currentChart.config as Object),
+        ...values,
+      },
+      { forceQuotes: true }
+    );
 
     api
       .upgradeChartValues(
@@ -118,7 +159,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         {
           namespace: this.props.currentChart.namespace,
           storage: StorageType.Secret,
-          values: valuesYaml,
+          values: conf,
         },
         {
           id: currentProject.id,
@@ -146,41 +187,11 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       });
   };
 
-  renderWebhookSection = () => {
-    if (!this.props.currentChart?.form?.hasSource) {
-      return;
-    }
-
-    if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
-      return (
-        <>
-          <Heading>Redeploy Webhook</Heading>
-          <Helper>
-            Programmatically deploy by calling this secret webhook.
-          </Helper>
-          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
-            <div>{webhookText}</div>
-            <i
-              className="material-icons"
-              onMouseLeave={() => this.setState({ highlightCopyButton: false })}
-            >
-              <CopyToClipboard
-                text={webhookText}
-                onSuccess={() => this.setState({ highlightCopyButton: true })}
-              />
-              content_copy
-            </i>
-          </Webhook>
-        </>
-      );
-    }
-  };
 
   render() {
     return (
       <Wrapper>
-        <StyledSettingsSection>
+        <StyledSettingsSection showSource={this.props.showSource}>
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
           <Button
@@ -190,6 +201,14 @@ export default class SettingsSection extends Component<PropsType, StateType> {
             Delete {this.props.currentChart.name}
           </Button>
         </StyledSettingsSection>
+        {this.props.showSource && (
+          <SaveButton
+            text="Deploy"
+            status={this.state.saveValuesStatus}
+            onClick={this.handleSubmit}
+            makeFlush={true}
+          />
+        )}
       </Wrapper>
     );
   }
@@ -197,6 +216,11 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 
 SettingsSection.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
 const Button = styled.button`
   height: 35px;
   font-size: 13px;
@@ -281,15 +305,15 @@ const Wrapper = styled.div`
   height: 100%;
 `;
 
-const StyledSettingsSection = styled.div`
+const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   width: 100%;
-  height: calc(100%);
   background: #ffffff11;
   padding: 0 35px;
   padding-bottom: 50px;
   position: relative;
   border-radius: 5px;
   overflow: auto;
+  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
 `;
 
 const Holder = styled.div`

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

@@ -148,6 +148,7 @@ export default class JobResource extends Component<PropsType, StateType> {
         </ExpandConfigBar>
       );
     } else {
+      let tag = job.spec.template.spec.containers[0].image.split(":")[1];
       return (
         <>
           <ExpandConfigBar
@@ -164,6 +165,9 @@ export default class JobResource extends Component<PropsType, StateType> {
             ) : (
               <DarkMatter size="-18px" />
             )}
+            <Row>
+              Image Tag: <Command>{tag}</Command>
+            </Row>
             {!_.isEmpty(envObject) && (
               <>
                 <KeyValueArray
@@ -275,6 +279,10 @@ export default class JobResource extends Component<PropsType, StateType> {
 
 JobResource.contextType = Context;
 
+const Row = styled.div`
+  margin-top: 20px;
+`;
+
 const DarkMatter = styled.div<{ size?: string }>`
   width: 100%;
   margin-bottom: ${(props) => props.size || "-13px"};

+ 21 - 10
internal/registry/registry.go

@@ -52,6 +52,9 @@ type Image struct {
 
 	// The name of the repository associated with the image.
 	RepositoryName string `json:"repository_name"`
+
+	// When the image was pushed
+	PushedAt *time.Time `json:"pushed_at"`
 }
 
 // ListRepositories lists the repositories for a registry
@@ -479,18 +482,26 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
+	describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+		RepositoryName: &repoName,
+		ImageIds:       resp.ImageIds,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
 	res := make([]*Image, 0)
 
-	for _, img := range resp.ImageIds {
-		if img.ImageTag == nil {
-			continue
+	for _, img := range describeResp.ImageDetails {
+		for _, tag := range img.ImageTags {
+			res = append(res, &Image{
+				Digest:         *img.ImageDigest,
+				Tag:            *tag,
+				RepositoryName: repoName,
+				PushedAt:       img.ImagePushedAt,
+			})
 		}
-
-		res = append(res, &Image{
-			Digest:         *img.ImageDigest,
-			Tag:            *img.ImageTag,
-			RepositoryName: repoName,
-		})
 	}
 
 	return res, nil
@@ -909,4 +920,4 @@ func (r *Registry) getPrivateRegistryDockerConfigFile(
 
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
-}
+}