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

Merge pull request #996 from porter-dev/0.7.0-ephemeral-pods

[0.7.0] `porter run` should spawn ephemeral pods
abelanger5 4 лет назад
Родитель
Сommit
d0ce2d5416

+ 106 - 0
cli/cmd/logs.go

@@ -0,0 +1,106 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+// logsCmd represents the "porter logs" base command when called
+// without any subcommands
+var logsCmd = &cobra.Command{
+	Use:   "logs [release]",
+	Short: "Logs the output from a given application.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, logs)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var follow bool
+
+func init() {
+	rootCmd.AddCommand(logsCmd)
+
+	logsCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of release to connect to",
+	)
+
+	logsCmd.PersistentFlags().BoolVarP(
+		&follow,
+		"follow",
+		"f",
+		false,
+		"specify if the logs should be streamed",
+	)
+}
+
+func logs(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	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
+	var selectedPod podSimple
+
+	if len(podsSimple) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
+	} else {
+		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)
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	return pipePodLogsToStdout(restConf, namespace, selectedPod.Name, selectedContainerName, follow)
+}

+ 204 - 3
cli/cmd/run.go

@@ -3,19 +3,25 @@ package cmd
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/kubectl/pkg/util/term"
+
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/remotecommand"
-	"k8s.io/kubectl/pkg/util/term"
 )
 
 var namespace string
@@ -35,6 +41,8 @@ var runCmd = &cobra.Command{
 	},
 }
 
+var existingPod bool
+
 func init() {
 	rootCmd.AddCommand(runCmd)
 
@@ -44,6 +52,14 @@ func init() {
 		"default",
 		"namespace of release to connect to",
 	)
+
+	runCmd.PersistentFlags().BoolVarP(
+		&existingPod,
+		"existing_pod",
+		"e",
+		false,
+		"whether to connect to an existing pod",
+	)
 }
 
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
@@ -106,7 +122,11 @@ 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, selectedPod.Name, selectedContainerName, args[1:])
+	if existingPod {
+		return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	}
+
+	return executeRunEphemeral(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
@@ -189,7 +209,6 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 		Namespace(namespace).
 		SubResource("exec")
 
-	// req.Param("container", "web")
 	for _, arg := range args {
 		req.Param("command", arg)
 	}
@@ -225,3 +244,185 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 
 	return err
 }
+
+func executeRunEphemeral(config *rest.Config, namespace, name, container string, args []string) error {
+	existing, err := getExistingPod(config, name, namespace)
+
+	if err != nil {
+		return err
+	}
+
+	newPod, err := createPodFromExisting(config, existing, args)
+
+	if err != nil {
+		return err
+	}
+
+	podName := newPod.ObjectMeta.Name
+
+	t := term.TTY{
+		In:  os.Stdin,
+		Out: os.Stdout,
+		Raw: true,
+	}
+
+	fn := func() error {
+		restClient, err := rest.RESTClientFor(config)
+
+		if err != nil {
+			return err
+		}
+
+		req := restClient.Post().
+			Resource("pods").
+			Name(podName).
+			Namespace("default").
+			SubResource("attach")
+
+		req.Param("stdin", "true")
+		req.Param("stdout", "true")
+		req.Param("tty", "true")
+		req.Param("container", container)
+
+		exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
+
+		if err != nil {
+			return err
+		}
+
+		return exec.Stream(remotecommand.StreamOptions{
+			Stdin:  os.Stdin,
+			Stdout: os.Stdout,
+			Stderr: os.Stderr,
+			Tty:    true,
+		})
+	}
+
+	color.New(color.FgYellow).Println("Attempting connection to the container, this may take up to 10 seconds. If you don't see a command prompt, try pressing enter.")
+
+	for i := 0; i < 5; i++ {
+		err = t.Safe(fn)
+
+		if err == nil {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+
+		// ugly way to catch non-TTY errors, such as when running command "echo \"hello\""
+		if i == 4 && err != nil && strings.Contains(err.Error(), "not found in pod") {
+			fmt.Printf("Could not open a shell to this container. Container logs:\n")
+
+			err = pipePodLogsToStdout(config, namespace, podName, container, false)
+		}
+	}
+
+	// delete the ephemeral pod
+	deletePod(config, podName, namespace)
+
+	return err
+}
+
+func pipePodLogsToStdout(config *rest.Config, namespace, name, container string, follow bool) error {
+	podLogOpts := v1.PodLogOptions{
+		Container: container,
+		Follow:    follow,
+	}
+
+	// creates the clientset
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return err
+	}
+
+	req := clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(
+		context.Background(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	defer podLogs.Close()
+
+	_, err = io.Copy(os.Stdout, podLogs)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func getExistingPod(config *rest.Config, name, namespace string) (*v1.Pod, error) {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+func deletePod(config *rest.Config, name, namespace string) error {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return err
+	}
+
+	return clientset.CoreV1().Pods(namespace).Delete(
+		context.Background(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+func createPodFromExisting(config *rest.Config, existing *v1.Pod, args []string) (*v1.Pod, error) {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	newPod := existing.DeepCopy()
+
+	// only copy the pod spec, overwrite metadata
+	newPod.ObjectMeta = metav1.ObjectMeta{
+		Name:      strings.ToLower(fmt.Sprintf("%s-copy-%s", existing.ObjectMeta.Name, utils.String(4))),
+		Namespace: existing.ObjectMeta.Namespace,
+	}
+
+	newPod.Status = v1.PodStatus{}
+
+	// set restart policy to never
+	newPod.Spec.RestartPolicy = v1.RestartPolicyNever
+
+	// change the command in the pod to the passed in pod command
+	cmdRoot := args[0]
+	cmdArgs := make([]string, 0)
+
+	if len(args) > 1 {
+		cmdArgs = args[1:]
+	}
+
+	newPod.Spec.Containers[0].Command = []string{cmdRoot}
+	newPod.Spec.Containers[0].Args = cmdArgs
+	newPod.Spec.Containers[0].TTY = true
+	newPod.Spec.Containers[0].Stdin = true
+	newPod.Spec.Containers[0].StdinOnce = true
+
+	// create the pod and return it
+	return clientset.CoreV1().Pods(existing.ObjectMeta.Namespace).Create(
+		context.Background(),
+		newPod,
+		metav1.CreateOptions{},
+	)
+}

+ 23 - 16
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -9,6 +9,9 @@ import { ChartType, StorageType } from "shared/types";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
+
 type PropsType = WithAuthProps & {
   showRevisions: boolean;
   toggleShowRevisions: () => void;
@@ -281,6 +284,26 @@ class RevisionSection extends Component<PropsType, StateType> {
       this.state.maxVersion === 0;
     return (
       <div>
+        {this.state.upgradeVersion &&
+              <Modal
+                onRequestClose={() => this.setState({ upgradeVersion: "" })}
+                width="500px"
+                height="450px"
+              >
+                <UpgradeChartModal 
+                  currentChart={this.props.chart}
+                  closeModal={() => {
+                    this.setState({ upgradeVersion: "" });
+                  }}
+                  onSubmit={() => {
+                    this.props.upgradeVersion(this.state.upgradeVersion, () => {
+                      this.setState({ loading: false });
+                    });
+                    this.setState({ upgradeVersion: "", loading: true });
+                  }}
+                />
+              </Modal>
+              }
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           isCurrent={isCurrent}
@@ -304,22 +327,6 @@ class RevisionSection extends Component<PropsType, StateType> {
                 <i className="material-icons">notification_important</i>
                 Template Update Available
               </RevisionUpdateMessage>
-              <ConfirmOverlay
-                show={!!this.state.upgradeVersion}
-                message={`Are you sure you want to redeploy and upgrade to version ${this.state.upgradeVersion}?`}
-                onYes={(e) => {
-                  e.stopPropagation();
-
-                  this.props.upgradeVersion(this.state.upgradeVersion, () => {
-                    this.setState({ loading: false });
-                  });
-                  this.setState({ upgradeVersion: "", loading: true });
-                }}
-                onNo={(e) => {
-                  e.stopPropagation();
-                  this.setState({ upgradeVersion: "" });
-                }}
-              />
             </div>
           )}
         </RevisionHeader>

+ 146 - 0
dashboard/src/main/home/modals/UpgradeChartModal.tsx

@@ -0,0 +1,146 @@
+import React, { Component, createRef } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+
+import Loading from "components/Loading";
+
+import Markdown from "markdown-to-jsx";
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  currentChart: ChartType;
+  onSubmit: () => void;
+  closeModal: () => void;
+};
+
+type StateType = {
+  notes: string;
+};
+
+export default class UpgradeChartModal extends Component<PropsType, StateType> {
+  state = {
+    notes: "Loading"
+  };
+
+  componentDidMount() {
+    // get the chart update notes from the api
+    let repoURL = process.env.ADDON_CHART_REPO_URL
+    let chartName = this.props.currentChart.chart.metadata.name.toLowerCase().trim()
+
+    if (chartName == "web" || chartName == "worker") {
+        repoURL = process.env.APPLICATION_CHART_REPO_URL
+    }
+
+    api
+    .getTemplateUpgradeNotes("<token>", {
+        repo_url: repoURL,
+        prev_version: this.props.currentChart.chart.metadata.version,
+     }, {
+      name: chartName,
+      version: this.props.currentChart.latest_version,
+    })
+    .then((res) => {
+      if (!res.data.upgrade_notes || res.data.upgrade_notes.length == 0) {
+        this.setState({ notes: `
+## Version ${this.props.currentChart.chart.metadata.version} -> ${this.props.currentChart.latest_version}
+No upgrade notes available. This update should be backwards-compatible. 
+        `})
+
+        return
+      }
+
+        let noteArr = res.data.upgrade_notes.map((note : any) => {
+            return `
+## Version ${note.previous} -> ${note.target}
+${note.note}
+            `
+        })
+
+        this.setState({ notes: noteArr.join("\n")})
+    })
+    .catch((err) => console.log(err));
+}
+
+  renderContent() {
+    if (this.state.notes == "Loading") {
+      return <Loading />
+    }
+
+    return <Markdown>{this.state.notes}</Markdown>
+  }
+
+  render() {
+    return (
+      <StyledUpgradeChartModal>
+        <CloseButton onClick={this.props.closeModal}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        {this.renderContent()}
+        <SaveButton
+          disabled={false}
+          text="Upgrade Template"
+          status={""}
+          onClick={this.props.onSubmit}
+        />
+      </StyledUpgradeChartModal>
+    );
+  }
+}
+
+UpgradeChartModal.contextType = Context;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 12px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpgradeChartModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+  font-size: 13px; 
+  line-height: 1.8em; 
+  font-family: Work Sans, sans-serif;
+`;

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

@@ -718,6 +718,16 @@ const getTemplateInfo = baseApi<
   return `/api/templates/${pathParams.name}/${pathParams.version}`;
 });
 
+const getTemplateUpgradeNotes = baseApi<
+  {
+    repo_url?: string;
+    prev_version: string;
+  },
+  { name: string; version: string }
+>("GET", (pathParams) => {
+  return `/api/templates/upgrade_notes/${pathParams.name}/${pathParams.version}`;
+});
+
 const getTemplates = baseApi<
   {
     repo_url?: string;
@@ -1072,6 +1082,7 @@ export default {
   getRepos,
   getRevisions,
   getTemplateInfo,
+  getTemplateUpgradeNotes,
   getTemplates,
   getUser,
   linkGithubProject,

+ 1 - 0
go.mod

@@ -6,6 +6,7 @@ require (
 	cloud.google.com/go v0.65.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/DATA-DOG/go-sqlmock v1.5.0
+	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation v1.1.1
 	github.com/buildpacks/pack v0.19.0

+ 2 - 0
go.sum

@@ -189,6 +189,7 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 h1:7aWHqerlJ41y6FOsEUvknqgXnGmJyJSbjhAWq5pO4F8=
 github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw=
 github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -327,6 +328,7 @@ github.com/evanphx/json-patch v4.9.0+incompatible h1:kLcOMZeuLAJvL2BPWLMIj5oaZQo
 github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM=
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4=
+github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8=
 github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=

+ 73 - 0
internal/helm/upgrade/upgrade.go

@@ -0,0 +1,73 @@
+package upgrade
+
+import (
+	semver "github.com/Masterminds/semver/v3"
+	"sigs.k8s.io/yaml"
+)
+
+// UpgradeFile is a collection of upgrade notes between specific versions
+type UpgradeFile struct {
+	UpgradeNotes []*UpgradeNote `yaml:"upgrade_notes" json:"upgrade_notes"`
+}
+
+// UpgradeNote is a single note for upgrading between a previous version and
+// a target version.
+type UpgradeNote struct {
+	PreviousVersion string `yaml:"previous" json:"previous"`
+	TargetVersion   string `yaml:"target" json:"target"`
+	Note            string `yaml:"note" json:"note"`
+}
+
+// ParseUpgradeFileFromBytes parses the raw bytes of an upgrade file and returns an
+// UpgradeFile object. sigs.k8s.io/yaml parser is used.
+func ParseUpgradeFileFromBytes(upgradeNotes []byte) (*UpgradeFile, error) {
+	// parse bytes into object
+	res := &UpgradeFile{}
+
+	err := yaml.Unmarshal(upgradeNotes, res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, err
+}
+
+// GetUpgradeFileBetweenVersions gets the set of upgrade notes that are applicable to an upgrade
+// between a previous and target version.
+func (u *UpgradeFile) GetUpgradeFileBetweenVersions(prev, target string) (*UpgradeFile, error) {
+	prevVersion, err := semver.NewVersion(prev)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// for each upgrade note, determine if it's geq than the previous version, leq the target
+	// version
+	resNotes := make([]*UpgradeNote, 0)
+
+	for _, note := range u.UpgradeNotes {
+		notePrevVersion, err := semver.NewVersion(note.PreviousVersion)
+
+		if err != nil {
+			return nil, err
+		}
+
+		noteTargetVersion, err := semver.NewVersion(note.TargetVersion)
+
+		if err != nil {
+			return nil, err
+		}
+
+		// if note(prev) <= prev and note(next) >= prev, render the note
+		if comp := notePrevVersion.Compare(prevVersion); comp != -1 {
+			if comp := noteTargetVersion.Compare(prevVersion); comp != -1 {
+				resNotes = append(resNotes, note)
+			}
+		}
+	}
+
+	return &UpgradeFile{
+		UpgradeNotes: resNotes,
+	}, nil
+}

+ 29 - 0
server/api/api.go

@@ -18,6 +18,7 @@ import (
 
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
@@ -81,6 +82,11 @@ type App struct {
 	// config for capabilities
 	Capabilities *AppCapabilities
 
+	// ChartLookupURLs contains an in-memory store of Porter chart names matched with
+	// a repo URL, so that finding a chart does not involve multiple lookups to our
+	// chart repo's index.yaml file
+	ChartLookupURLs map[string]string
+
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
@@ -238,6 +244,8 @@ func New(conf *AppConfig) (*App, error) {
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
 	app.analyticsClient = newSegmentClient
 
+	app.updateChartRepoURLs()
+
 	return app, nil
 }
 
@@ -312,3 +320,24 @@ func (app *App) getTokenFromRequest(r *http.Request) *token.Token {
 
 	return tok
 }
+
+func (app *App) updateChartRepoURLs() {
+	newCharts := make(map[string]string)
+
+	for _, chartRepo := range []string{
+		app.ServerConf.DefaultApplicationHelmRepoURL,
+		app.ServerConf.DefaultAddonHelmRepoURL,
+	} {
+		indexFile, err := loader.LoadRepoIndexPublic(chartRepo)
+
+		if err != nil {
+			continue
+		}
+
+		for chartName, _ := range indexFile.Entries {
+			newCharts[chartName] = chartRepo
+		}
+	}
+
+	app.ChartLookupURLs = newCharts
+}

+ 38 - 13
server/api/release_handler.go

@@ -88,8 +88,6 @@ type PorterRelease struct {
 	ImageRepoURI    string                          `json:"image_repo_uri"`
 }
 
-var porterApplications = map[string]string{"web": "", "job": "", "worker": ""}
-
 // HandleGetRelease retrieves a single release based on a name and revision
 func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
@@ -206,12 +204,22 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 
 	// detect if Porter application chart and attempt to get the latest version
 	// from chart repo
-	if _, found := porterApplications[res.Chart.Metadata.Name]; found {
-		repoIndex, err := loader.LoadRepoIndexPublic(app.ServerConf.DefaultApplicationHelmRepoURL)
+	chartRepoURL, firstFound := app.ChartLookupURLs[res.Chart.Metadata.Name]
+
+	if !firstFound {
+		app.updateChartRepoURLs()
+
+		chartRepoURL, _ = app.ChartLookupURLs[res.Chart.Metadata.Name]
+	}
+
+	if chartRepoURL != "" {
+		repoIndex, err := loader.LoadRepoIndexPublic(chartRepoURL)
 
 		if err == nil {
 			porterChart := loader.FindPorterChartInIndexList(repoIndex, res.Chart.Metadata.Name)
 
+			fmt.Println("PORTER CHART IS", porterChart.Versions)
+
 			if porterChart != nil && len(porterChart.Versions) > 0 {
 				res.LatestVersion = porterChart.Versions[0]
 			}
@@ -952,20 +960,22 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		if err != nil {
 			app.sendExternalError(err, http.StatusNotFound, HTTPError{
 				Code:   ErrReleaseReadData,
-				Errors: []string{"release not found"},
+				Errors: []string{"chart version not found"},
 			}, w)
 
 			return
 		}
 
-		if _, found := porterApplications[release.Chart.Metadata.Name]; found {
-			chart, err := loader.LoadChartPublic(
-				app.ServerConf.DefaultApplicationHelmRepoURL,
-				release.Chart.Metadata.Name,
-				form.ChartVersion,
-			)
+		chartRepoURL, foundFirst := app.ChartLookupURLs[release.Chart.Metadata.Name]
 
-			if err != nil {
+		if !foundFirst {
+			app.updateChartRepoURLs()
+
+			var found bool
+
+			chartRepoURL, found = app.ChartLookupURLs[release.Chart.Metadata.Name]
+
+			if !found {
 				app.sendExternalError(err, http.StatusNotFound, HTTPError{
 					Code:   ErrReleaseReadData,
 					Errors: []string{"chart not found"},
@@ -973,9 +983,24 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 				return
 			}
+		}
+
+		chart, err := loader.LoadChartPublic(
+			chartRepoURL,
+			release.Chart.Metadata.Name,
+			form.ChartVersion,
+		)
+
+		if err != nil {
+			app.sendExternalError(err, http.StatusNotFound, HTTPError{
+				Code:   ErrReleaseReadData,
+				Errors: []string{"chart not found"},
+			}, w)
 
-			conf.Chart = chart
+			return
 		}
+
+		conf.Chart = chart
 	}
 
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))

+ 63 - 0
server/api/template_handler.go

@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/helm/upgrade"
 	"github.com/porter-dev/porter/internal/templater/parser"
 
 	"github.com/porter-dev/porter/internal/models"
@@ -101,3 +102,65 @@ func (app *App) HandleReadTemplate(w http.ResponseWriter, r *http.Request) {
 
 	json.NewEncoder(w).Encode(res)
 }
+
+// HandleGetTemplateUpgradeNotes gets the upgrade notes for a template
+func (app *App) HandleGetTemplateUpgradeNotes(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	version := chi.URLParam(r, "version")
+
+	// if version passed as latest, pass empty string to loader to get latest
+	if version == "latest" {
+		version = ""
+	}
+
+	form := &forms.ChartForm{
+		Name:    name,
+		Version: version,
+		RepoURL: app.ServerConf.DefaultApplicationHelmRepoURL,
+	}
+
+	// look for the prev_version in the query params
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	form.PopulateRepoURLFromQueryParams(vals)
+
+	prevVersion := "v0.0.0"
+
+	if prevVersionArr, ok := vals["prev_version"]; ok && len(prevVersionArr) == 1 {
+		prevVersion = prevVersionArr[0]
+	}
+
+	chart, err := loader.LoadChartPublic(form.RepoURL, form.Name, form.Version)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	res := &upgrade.UpgradeFile{}
+
+	for _, file := range chart.Files {
+		if strings.Contains(file.Name, "upgrade.yaml") {
+			upgradeFile, err := upgrade.ParseUpgradeFileFromBytes(file.Data)
+
+			if err != nil {
+				break
+			}
+
+			upgradeFile, err = upgradeFile.GetUpgradeFileBetweenVersions(prevVersion, version)
+
+			if err != nil {
+				break
+			}
+
+			res = upgradeFile
+		}
+	}
+
+	json.NewEncoder(w).Encode(res)
+}

+ 8 - 0
server/router/router.go

@@ -232,6 +232,14 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/templates/upgrade_notes/{name}/{version}",
+				auth.BasicAuthenticate(
+					requestlog.NewHandler(a.HandleGetTemplateUpgradeNotes, l),
+				),
+			)
+
 			// /api/oauth routes
 			r.Method(
 				"GET",