Explorar o código

Merge branch 'fix-buildpack-subdir' into docker-build-context

merge buildpack subdir fix
Alexander Belanger %!s(int64=4) %!d(string=hai) anos
pai
achega
be1fdc6593

+ 4 - 1
Makefile

@@ -11,4 +11,7 @@ setup-env-files:
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 
 build-cli: 
-	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
+	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
+
+build-cli-dev:
+	go build -tags cli -o $(BINDIR)/porter ./cli

+ 6 - 5
cli/cmd/deploy/deploy.go

@@ -280,16 +280,17 @@ func (d *DeployAgent) Push() error {
 // reuses the configuration set for the application. If overrideValues is not nil,
 // it will merge the overriding values with the existing configuration.
 func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
+	// if this is a job chart, set "paused" to false so that the job doesn't run, unless
+	// the user has explicitly overriden the "paused" field
+	if _, exists := overrideValues["paused"]; d.release.Chart.Name() == "job" && !exists {
+		overrideValues["paused"] = true
+	}
+
 	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
 
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
-	// if this is a job chart, set "paused" to false so that the job doesn't run
-	if d.release.Chart.Name() == "job" {
-		mergedValues["paused"] = true
-	}
-
 	// if the current image section is hello-porter, the image must be overriden
 	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
 		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {

+ 15 - 6
cli/cmd/pack/pack.go

@@ -3,6 +3,7 @@ package pack
 import (
 	"context"
 	"fmt"
+	"path/filepath"
 
 	"github.com/buildpacks/pack"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -16,16 +17,24 @@ func (a *Agent) Build(opts *docker.BuildOpts) error {
 
 	//initialize a pack client
 	client, err := pack.NewClient()
+
+	if err != nil {
+		return err
+	}
+
+	absPath, err := filepath.Abs(opts.BuildContext)
+
 	if err != nil {
-		panic(err)
+		return err
 	}
 
 	buildOpts := pack.BuildOptions{
-		Image:        fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
-		Builder:      "heroku/buildpacks:18",
-		AppPath:      opts.BuildContext,
-		TrustBuilder: true,
-		Env:          opts.Env,
+		RelativeBaseDir: filepath.Dir(absPath),
+		Image:           fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
+		Builder:         "heroku/buildpacks:18",
+		AppPath:         opts.BuildContext,
+		TrustBuilder:    true,
+		Env:             opts.Env,
 	}
 
 	return client.Build(context, buildOpts)

+ 5 - 3
cmd/migrate/keyrotate/rotate.go

@@ -127,7 +127,7 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		clusters := []*models.Cluster{}
 
-		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&clusters).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&clusters).Error; err != nil {
 			return err
 		}
 
@@ -143,8 +143,10 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 
 				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
 				// recover it
-				cluster.CertificateAuthorityData = []byte{}
-				cluster.TokenCache.Token = []byte{}
+				if err := db.Unscoped().Where("id = ?", cluster.TokenCacheID).Delete(&ints.ClusterTokenCache{}).Error; err != nil {
+					return err
+				}
+				cluster.TokenCacheID = 0
 			}
 		}
 

+ 9 - 0
cmd/migrate/main.go

@@ -34,6 +34,15 @@ func main() {
 		return
 	}
 
+	if err := db.Raw("ALTER TABLE clusters DROP CONSTRAINT IF EXISTS fk_cluster_token_caches").Error; err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+	if err := db.Raw("ALTER TABLE cluster_token_caches DROP CONSTRAINT IF EXISTS fk_clusters_token_cache").Error; err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {
 		oldKey := [32]byte{}
 		newKey := [32]byte{}

+ 17 - 10
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -12,6 +12,7 @@ import Modal from "../../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
 import { hasSetValue } from "../utils";
+import _ from "lodash";
 
 interface Props extends KeyValueArrayField {
   id: string;
@@ -166,17 +167,23 @@ const KeyValueArray: React.FC<Props> = (props) => {
             }
             setValues={(values) => {
               setState((prev) => {
+                // Transform array to object similar on what we receive from setValues
+                const prevValues = prev.values.reduce((acc, currentValue) => {
+                  acc[currentValue.key] = currentValue.value;
+                  return acc;
+                }, {} as Record<string, string>)
+
+                // Deconstruct the two records/objects inside one to merge their values (this will override the old duped vars too)
+                // and convert the new object back to an array usable for the component
+                const newValues = Object.entries({...prevValues, ...values})?.map(([k, v]) => {
+                  return {
+                    key: k,
+                    value: v,
+                  };
+                });
+
                 return {
-                  // might be broken
-                  values: [
-                    ...prev.values,
-                    ...Object.entries(values)?.map(([k, v]) => {
-                      return {
-                        key: k,
-                        value: v,
-                      };
-                    }),
-                  ],
+                  values: [...newValues]
                 };
               });
             }}

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

@@ -15,6 +15,7 @@ import CopyToClipboard from "components/CopyToClipboard";
 import useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
 import NotificationSettingsSection from "./NotificationSettingsSection";
+import { Link } from "react-router-dom";
 
 type PropsType = {
   currentChart: ChartType;
@@ -174,6 +175,21 @@ const SettingsSection: React.FC<PropsType> = ({
     }
   };
 
+  const getCloneUrl = () => {
+    const params = new URLSearchParams();
+    params.append("project_id", currentProject.id.toString());
+    params.append("shouldClone", "true");
+    params.append("release_namespace", currentChart.namespace);
+    params.append(
+      "release_template_version",
+      currentChart.chart.metadata.version
+    );
+    params.append("release_type", currentChart.chart.metadata.name);
+    params.append("release_name", currentChart.name);
+    params.append("release_version", currentChart.version.toString());
+    return `/launch?${params.toString()}`;
+  };
+
   const renderWebhookSection = () => {
     if (!currentChart?.form?.hasSource) {
       return;
@@ -264,13 +280,48 @@ const SettingsSection: React.FC<PropsType> = ({
     );
   };
 
+  const chartWasDeployedWithGithub = () => {
+    if (currentChart.git_action_config) {
+      return true;
+    }
+    return false;
+  };
+
+  const canBeCloned = () => {
+    if(chartWasDeployedWithGithub()) {
+      return false;
+    }
+
+    // If its not web worker or job it means is an addon, and for now it's not supported
+    if (!["web", "worker", "job"].includes(currentChart?.chart?.metadata?.name)) {
+      return false
+    }
+
+    return true
+  }
+
   return (
     <Wrapper>
       {!loadingWebhookToken ? (
         <StyledSettingsSection>
           {renderWebhookSection()}
           <NotificationSettingsSection currentChart={currentChart} />
+          {/* Prevent the clone button to be rendered in github deployed charts */}
+          {canBeCloned() && (
+            <>
+              <Heading>Clone deployment</Heading>
+              <Helper>
+                Click the button below to be redirected to the deploy form with
+                all the data prefilled
+              </Helper>
+              <CloneButton as={Link} to={getCloneUrl()}>
+                Clone
+              </CloneButton>
+            </>
+          )}
+
           <Heading>Additional Settings</Heading>
+
           <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
             Delete {currentChart.name}
           </Button>
@@ -314,6 +365,17 @@ const Button = styled.button`
   }
 `;
 
+const CloneButton = styled(Button)`
+  display: flex;
+  width: min-content;
+  align-items: center;
+  justify-content: center;
+  background-color: #ffffff11;
+  :hover {
+    background-color: #ffffff18;
+  }
+`;
+
 const Webhook = styled.div`
   width: 100%;
   border: 1px solid #ffffff55;

+ 34 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -4,6 +4,8 @@ import { Context } from "shared/Context";
 import * as Anser from "anser";
 import api from "shared/api";
 
+const MAX_LOGS = 1000;
+
 type PropsType = {
   selectedPod: any;
   podError: string;
@@ -11,15 +13,19 @@ type PropsType = {
 };
 
 type StateType = {
-  logs: Anser.AnserJsonEntry[][];
+  logs: [number, Anser.AnserJsonEntry[]][];
+  numLogs: number;
   ws: any;
   scroll: boolean;
   currentTab: string;
 };
 
 export default class Logs extends Component<PropsType, StateType> {
+  private numLogs: React.RefObject<number>;
+
   state = {
-    logs: [] as Anser.AnserJsonEntry[][],
+    logs: [] as [number, Anser.AnserJsonEntry[]][],
+    numLogs: 0,
     ws: null as any,
     scroll: true,
     currentTab: "Application",
@@ -76,15 +82,16 @@ export default class Logs extends Component<PropsType, StateType> {
     }
 
     return this.state.logs.map((log, i) => {
+      const key = log[0];
       return (
-        <Log key={i}>
-          {this.state.logs[i].map((ansi, j) => {
+        <Log key={key}>
+          {this.state.logs[i][1].map((ansi, j) => {
             if (ansi.clearLine) {
               return null;
             }
 
             return (
-              <LogSpan key={i + "." + j} ansi={ansi}>
+              <LogSpan key={key + "." + j} ansi={ansi}>
                 {ansi.content.replace(/ /g, "\u00a0")}
               </LogSpan>
             );
@@ -109,13 +116,28 @@ export default class Logs extends Component<PropsType, StateType> {
       let ansiLog = Anser.ansiToJson(evt.data);
 
       let logs = this.state.logs;
-      logs.push(ansiLog);
+      logs.push([this.state.numLogs, ansiLog]);
+
+      // this is technically not as efficient as things could be
+      // if there are performance issues, a deque can be used in place of a list
+      // for storing logs
+      if (logs.length > MAX_LOGS) {
+        logs.shift();
+      }
 
-      this.setState({ logs: logs }, () => {
-        if (this.state.scroll) {
-          this.scrollToBottom(false);
+      this.setState(
+        (prev) => {
+          return {
+            logs: prev.logs,
+            numLogs: prev.numLogs + 1,
+          };
+        },
+        () => {
+          if (this.state.scroll) {
+            this.scrollToBottom(false);
+          }
         }
-      });
+      );
     };
 
     this.ws.onerror = (err: ErrorEvent) => {};
@@ -167,7 +189,7 @@ export default class Logs extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        let logs = [] as Anser.AnserJsonEntry[][];
+        let logs = [] as [number, Anser.AnserJsonEntry[]][];
         // TODO: column view
         // logs.push(Anser.ansiToJson("\u001b[33;5;196mEvent Type\u001b[0m \t || \t \u001b[43m\u001b[34m\tReason\t\u001b[0m \t ||\tMessage"))
 
@@ -176,7 +198,7 @@ export default class Logs extends Component<PropsType, StateType> {
           let ansiLog = Anser.ansiToJson(
             `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
           );
-          logs.push(ansiLog);
+          logs.push([logs.length, ansiLog]);
         });
         this.setState({ logs: logs });
         console.log(res);

+ 161 - 66
dashboard/src/main/home/launch/Launch.tsx

@@ -3,7 +3,11 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { PorterTemplate } from "shared/types";
+import {
+  ChartTypeWithExtendedConfig,
+  PorterTemplate,
+  StorageType,
+} from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
@@ -14,13 +18,15 @@ import TitleSection from "components/TitleSection";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import semver from "semver";
+import { RouteComponentProps, withRouter } from "react-router";
+import { getQueryParam, getQueryParams } from "shared/routing";
 
 const tabOptions = [
   { label: "New Application", value: "porter" },
   { label: "Community Add-ons", value: "community" },
 ];
 
-type PropsType = {};
+type PropsType = RouteComponentProps & {};
 
 type StateType = {
   currentTemplate: PorterTemplate | null;
@@ -31,9 +37,10 @@ type StateType = {
   loading: boolean;
   error: boolean;
   isOnLaunchFlow: boolean;
+  clonedChart: ChartTypeWithExtendedConfig;
 };
 
-export default class Templates extends Component<PropsType, StateType> {
+class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
     form: null as any,
@@ -43,84 +50,165 @@ export default class Templates extends Component<PropsType, StateType> {
     loading: true,
     error: false,
     isOnLaunchFlow: false,
+    clonedChart: null as ChartTypeWithExtendedConfig,
   };
 
-  componentDidMount() {
-    api
-      .getTemplates(
+  async componentDidMount() {
+    try {
+      const res = await api.getTemplates(
         "<token>",
         {
           repo_url: process.env.ADDON_CHART_REPO_URL,
         },
         {}
-      )
-      .then((res) => {
-        let sortedVersionData = res.data.map((template: any) => {
-          let versions = template.versions.reverse();
-
-          versions = template.versions.sort(semver.rcompare);
-
-          return {
-            ...template,
-            versions,
-            currentVersion: versions[0],
-          };
-        });
-
-        this.setState(
-          { addonTemplates: sortedVersionData, error: false },
-          () => {
-            this.state.addonTemplates.sort((a, b) =>
-              a.name > b.name ? 1 : -1
-            );
-
-            this.setState({
-              loading: false,
-            });
-          }
-        );
-      })
-      .catch(() => this.setState({ loading: false, error: true }));
-
-    api
-      .getTemplates(
+      );
+      let sortedVersionData = res.data.map((template: any) => {
+        let versions = template.versions.reverse();
+
+        versions = template.versions.sort(semver.rcompare);
+
+        return {
+          ...template,
+          versions,
+          currentVersion: versions[0],
+        };
+      });
+      sortedVersionData.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+
+      this.setState({ addonTemplates: sortedVersionData, error: false });
+    } catch (error) {
+      this.setState({ loading: false, error: true });
+    }
+    try {
+      const res = await api.getTemplates(
         "<token>",
         {
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
         },
         {}
-      )
-      .then((res) => {
-        let sortedVersionData = res.data.map((template: any) => {
-          let versions = template.versions.reverse();
-
-          versions = template.versions.sort(semver.rcompare);
-
-          return {
-            ...template,
-            versions,
-            currentVersion: versions[0],
-          };
-        });
-
-        this.setState(
-          { applicationTemplates: sortedVersionData, error: false },
-          () => {
-            let preferredOrder = ["web", "worker", "job"];
-            this.state.applicationTemplates.sort((a, b) => {
-              return (
-                preferredOrder.indexOf(a.name) - preferredOrder.indexOf(b.name)
-              );
-            });
-            this.setState({
-              loading: false,
-            });
-          }
+      );
+      let sortedVersionData = res.data.map((template: any) => {
+        let versions = template.versions.reverse();
+
+        versions = template.versions.sort(semver.rcompare);
+
+        return {
+          ...template,
+          versions,
+          currentVersion: versions[0],
+        };
+      });
+
+      let currentTemplate = null;
+      let isOnLaunchFlow = false;
+      let form = null;
+      let clonedChart = null;
+      if (this.isTryingToClone() && this.areCloneQueryParamsValid()) {
+        isOnLaunchFlow = true;
+        const template_name = getQueryParam(this.props, "release_type");
+        const version = getQueryParam(this.props, "release_template_version");
+        currentTemplate = sortedVersionData.find(
+          (v: any) => v.name === template_name
         );
-      })
-      .catch(() => this.setState({ loading: false, error: true }));
+
+        console.log(currentTemplate);
+        if (currentTemplate.versions.find((v: any) => v === version)) {
+          currentTemplate.currentVersion = version;
+        }
+        const release = await this.getClonedRelease().then((res) => res.data);
+        form = release.form;
+        clonedChart = release;
+        if (release.git_action_config) {
+          this.context.setCurrentError(
+            "Application/Jobs deployed with GitHub are not supported for cloning yet!"
+          );
+          this.props.history.push("/dashboard");
+          return;
+        }
+        // If its not web worker or job it means is an addon, and for now it's not supported
+        if (!["web", "worker", "job"].includes(release?.chart?.metadata?.name)) {
+          this.context.setCurrentError(
+            "Addons don't support cloning yet!"
+          );
+          this.props.history.push("/dashboard");
+          return;
+        }
+      }
+
+      this.setState(
+        {
+          applicationTemplates: sortedVersionData,
+          error: false,
+          currentTemplate,
+          isOnLaunchFlow,
+          form,
+          clonedChart,
+        },
+        () => {
+          let preferredOrder = ["web", "worker", "job"];
+          this.state.applicationTemplates.sort((a, b) => {
+            return (
+              preferredOrder.indexOf(a.name) - preferredOrder.indexOf(b.name)
+            );
+          });
+          this.setState({
+            loading: false,
+          });
+        }
+      );
+    } catch (error) {
+      this.setState({ loading: false, error: true });
+    }
   }
 
+  isTryingToClone = () => {
+    const queryParams = getQueryParams({ location });
+    return queryParams.has("shouldClone");
+  };
+
+  areCloneQueryParamsValid = () => {
+    const qp = getQueryParams(this.props);
+
+    const requiredParams = [
+      "release_namespace",
+      "release_template_version",
+      "release_name",
+      "release_version",
+      "release_type",
+    ];
+    // Check if we have all the params we need to make the request for the cloned app
+    // If the any param is missing then the some function will return true, so the validation
+    // went wrong.
+    return !requiredParams.some((rp) => !qp.has(rp));
+  };
+
+  getClonedRelease = () => {
+    const queryParams = getQueryParams(this.props);
+
+    if (!this.areCloneQueryParamsValid()) {
+      this.context.setCurrentError(
+        "Url has missing params to clone the app. Please try again."
+      );
+      this.props.history.push("/dashboard");
+      return;
+    }
+
+    return api.getChart<ChartTypeWithExtendedConfig>(
+      "<token>",
+      {
+        namespace: queryParams.get("release_namespace"),
+        cluster_id: this.context?.currentCluster?.id,
+        storage: StorageType.Secret,
+      },
+      {
+        id: this.context.currentProject.id,
+        name: queryParams.get("release_name"),
+        // This will get by default the last available version
+        revision: Number(queryParams.get("release_version")),
+      }
+    );
+  };
+
   renderIcon = (icon: string) => {
     if (icon) {
       return <Icon src={icon} />;
@@ -232,6 +320,9 @@ export default class Templates extends Component<PropsType, StateType> {
   };
 
   render() {
+    if (this.isTryingToClone() && this.state.loading) {
+      return <Loading />;
+    }
     if (!this.state.isOnLaunchFlow || !this.state.currentTemplate) {
       return (
         <TemplatesWrapper>
@@ -247,6 +338,8 @@ export default class Templates extends Component<PropsType, StateType> {
     } else {
       return (
         <LaunchFlow
+          isCloning={this.isTryingToClone()}
+          clonedChart={this.state.clonedChart}
           form={this.state.form}
           currentTab={this.state.currentTab}
           currentTemplate={this.state.currentTemplate}
@@ -259,6 +352,8 @@ export default class Templates extends Component<PropsType, StateType> {
 
 Templates.contextType = Context;
 
+export default withRouter(Templates);
+
 const Placeholder = styled.div`
   padding-top: 200px;
   width: 100%;

+ 2 - 2
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -119,10 +119,10 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
 const FadeWrapper = styled.div`
   animation: fadeIn 0.2s;
   @keyframes fadeIn {
-    from: {
+    from {
       opacity: 0;
     }
-    to: {
+    to {
       opacity: 1;
     }
   }

+ 47 - 21
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -6,7 +6,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { pushFiltered } from "shared/routing";
+import { getQueryParam, getQueryParams, pushFiltered } from "shared/routing";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
@@ -16,6 +16,7 @@ import TitleSection from "components/TitleSection";
 
 import {
   ActionConfigType,
+  ChartTypeWithExtendedConfig,
   FullActionConfigType,
   PorterTemplate,
   StorageType,
@@ -26,6 +27,8 @@ type PropsType = RouteComponentProps & {
   currentTemplate: PorterTemplate;
   hideLaunchFlow: () => void;
   form: any;
+  isCloning: boolean;
+  clonedChart: ChartTypeWithExtendedConfig;
 };
 
 const defaultActionConfig: ActionConfigType = {
@@ -38,7 +41,9 @@ const defaultActionConfig: ActionConfigType = {
 const LaunchFlow: React.FC<PropsType> = (props) => {
   const context = useContext(Context);
 
-  const [currentPage, setCurrentPage] = useState("source");
+  const [currentPage, setCurrentPage] = useState(
+    props.isCloning ? "settings" : "source"
+  );
   const [templateName, setTemplateName] = useState("");
   const [saveValuesStatus, setSaveValuesStatus] = useState("");
   const [sourceType, setSourceType] = useState("");
@@ -60,25 +65,23 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   const [selectedRegistry, setSelectedRegistry] = useState(null);
   const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
 
-  const setRandomNameIfEmpty = () => {
-    if (!templateName) {
-      const randomTemplateName = randomWords({ exactly: 3, join: "-" });
-      setTemplateName(randomTemplateName);
-    }
+  const generateRandomName = () => {
+    const randomTemplateName = randomWords({ exactly: 3, join: "-" });
+    return randomTemplateName;
   };
 
   const getFullActionConfig = (): FullActionConfigType => {
-    let imageRepoUri = `${selectedRegistry.url}/${templateName}-${selectedNamespace}`;
+    let imageRepoUri = `${selectedRegistry?.url}/${templateName}-${selectedNamespace}`;
 
     // DockerHub registry integration is per repo
-    if (selectedRegistry.service === "dockerhub") {
-      imageRepoUri = selectedRegistry.url;
+    if (selectedRegistry?.service === "dockerhub") {
+      imageRepoUri = selectedRegistry?.url;
     }
 
     return {
       git_repo: actionConfig.git_repo,
       branch: branch,
-      registry_id: selectedRegistry.id,
+      registry_id: selectedRegistry?.id,
       dockerfile_path: dockerfilePath,
       folder_path: folderPath,
       image_repo_uri: imageRepoUri,
@@ -91,6 +94,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
     let { currentCluster, currentProject, setCurrentError } = context;
     setSaveValuesStatus("loading");
 
+    const name = templateName || generateRandomName();
+
     let values = {};
     for (let key in wildcard) {
       _.set(values, key, wildcard[key]);
@@ -104,7 +109,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name: templateName,
+          name,
         },
         {
           id: currentProject.id,
@@ -159,8 +164,14 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       _.set(values, key, rawValues[key]);
     }
 
-    let url = imageUrl,
-      tag = imageTag;
+    let url = imageUrl;
+    let tag = imageTag;
+
+    if (props.isCloning) {
+      url = props.clonedChart.config.image.repository;
+      tag = props.clonedChart.config.image.tag;
+    }
+
     if (url.includes(":")) {
       let splits = url.split(":");
       url = splits[0];
@@ -208,6 +219,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
     }
 
     var external_domain: string;
+
+    const release_name = templateName || generateRandomName();
     // check if template is docker and create external domain if necessary
     if (props.currentTemplate.name == "web") {
       if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
@@ -216,7 +229,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
             .createSubdomain(
               "<token>",
               {
-                release_name: templateName,
+                release_name,
               },
               {
                 id: currentProject.id,
@@ -242,7 +255,11 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
 
     let githubActionConfig: FullActionConfigType = null;
     if (sourceType === "repo") {
-      githubActionConfig = getFullActionConfig();
+      if (props.isCloning) {
+        githubActionConfig = props.clonedChart?.git_action_config;
+      } else {
+        githubActionConfig = getFullActionConfig();
+      }
     }
 
     api
@@ -254,7 +271,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name: templateName,
+          name: release_name,
           githubActionConfig,
         },
         {
@@ -322,7 +339,10 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       );
     }
 
-    setRandomNameIfEmpty();
+    if (!templateName && !props.isCloning && currentTab === "porter") {
+      const newTemplateName = generateRandomName();
+      setTemplateName(newTemplateName);
+    }
 
     if (currentPage === "workflow" && currentTab === "porter") {
       const fullActionConfig = getFullActionConfig();
@@ -341,6 +361,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
     // Display main (non-source) settings page
     return (
       <SettingsPage
+        isCloning={props.isCloning}
         onSubmit={currentTab === "porter" ? handleSubmit : handleSubmitAddon}
         saveValuesStatus={saveValuesStatus}
         selectedNamespace={selectedNamespace}
@@ -377,10 +398,14 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   }
 
   return (
-    <StyledLaunchFlow>
+    <StyledLaunchFlow disableMarginTop={props.isCloning}>
       <TitleSection handleNavBack={props.hideLaunchFlow}>
         {renderIcon()}
-        New {currentTemplateName} {currentTab === "porter" ? null : "Instance"}
+        {!props.isCloning
+          ? `New ${currentTemplateName} ${
+              currentTab !== "porter" ? "Instance" : ""
+            }`
+          : `Cloning ${currentTemplateName} deployment: ${props.clonedChart.name}`}
       </TitleSection>
       {renderCurrentPage()}
       <Br />
@@ -428,5 +453,6 @@ const Polymer = styled.div`
 const StyledLaunchFlow = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
-  margin-top: calc(50vh - 380px);
+  margin-top: ${(props: { disableMarginTop: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
 `;

+ 34 - 22
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -30,6 +30,7 @@ type PropsType = WithAuthProps & {
   selectedNamespace: string;
   setSelectedNamespace: (x: string) => void;
   saveValuesStatus: string;
+  isCloning: boolean;
 };
 
 type StateType = {
@@ -182,28 +183,8 @@ class SettingsPage extends Component<PropsType, StateType> {
     }
   };
 
-  renderHeaderSection = () => {
-    let {
-      hasSource,
-      sourceType,
-      templateName,
-      setPage,
-      setTemplateName,
-    } = this.props;
-
-    if (hasSource) {
-      const [pageKey, pageName] =
-        sourceType === "repo"
-          ? ["workflow", "GitHub Actions"]
-          : ["source", "Source Settings"];
-
-      return (
-        <BackButton width="155px" onClick={() => setPage(pageKey)}>
-          <i className="material-icons">first_page</i>
-          {pageName}
-        </BackButton>
-      );
-    }
+  getNameInput = () => {
+    const { templateName, setTemplateName } = this.props;
 
     return (
       <>
@@ -229,6 +210,36 @@ class SettingsPage extends Component<PropsType, StateType> {
     );
   };
 
+  renderHeaderSection = () => {
+    let {
+      hasSource,
+      sourceType,
+      templateName,
+      setPage,
+      setTemplateName,
+    } = this.props;
+
+    if (this.props.isCloning) {
+      return null;
+    }
+
+    if (hasSource) {
+      const [pageKey, pageName] =
+        sourceType === "repo"
+          ? ["workflow", "GitHub Actions"]
+          : ["source", "Source Settings"];
+
+      return (
+        <BackButton width="155px" onClick={() => setPage(pageKey)}>
+          <i className="material-icons">first_page</i>
+          {pageName}
+        </BackButton>
+      );
+    }
+
+    return this.getNameInput();
+  };
+
   render() {
     let { selectedCluster } = this.state;
 
@@ -238,6 +249,7 @@ class SettingsPage extends Component<PropsType, StateType> {
       <PaddingWrapper>
         <StyledSettingsPage>
           {this.renderHeaderSection()}
+          {this.props.isCloning && this.getNameInput()}
           <Heading>Destination</Heading>
           <Helper>
             Specify the cluster and namespace you would like to deploy your

+ 1 - 1
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -34,7 +34,7 @@ export default class ClusterInstructionsModal extends Component<
               <br />
               name=$(curl -s
               https://api.github.com/repos/porter-dev/porter/releases/latest |
-              grep "browser_download_url.*porter_.*_Darwin_x86_64\.zip" | cut -d
+              grep "browser_download_url.*/porter_.*_Darwin_x86_64\.zip" | cut -d
               ":" -f 2,3 | tr -d \")
               <br />
               name=$(basename $name)

+ 14 - 5
docs/guides/jobs-and-cron-jobs.md

@@ -1,4 +1,4 @@
-You can create one-time jobs or cron jobs on Porter, which can be linked [from your Github repo](https://docs.getporter.dev/docs/applications) or [from an existing Docker image registry](https://docs.getporter.dev/docs/deploying-from-docker-image-registry). Cron jobs are meant to run on a schedule using a specified [cron expression](https://en.wikipedia.org/wiki/Cron#CRON_expression), while one-time jobs are meant to be triggered manually or on every push to your Github repository. Here are some use-cases for each type of job:
+You can create one-time jobs or cron jobs on Porter, which can be linked [from your Github repo](https://docs.getporter.dev/docs/applications) or [from an existing Docker image registry](https://docs.getporter.dev/docs/deploying-from-docker-image-registry). Cron jobs are meant to run on a schedule using a specified [cron expression](https://en.wikipedia.org/wiki/Cron#CRON_expression), while one-time jobs are meant to be triggered manually. Here are some use-cases for each type of job:
 
 - Run one-time jobs for database migration scripts, data processing, or generally scripts that are designed to run to completion on an unpredictable schedule
 - Run cron jobs for tasks that should run on a specified schedule, such as scraping data at a specified interval, cleaning up rows in a database, taking backups of a DB, or sending batch notifications at a specified time every day
@@ -23,11 +23,20 @@ To re-run the job, simply click the "Rerun job" button in the bottom right corne
 
 ## Running One-Time Jobs from Github Repositories
 
-When you set up a one-time job to deploy from a Github repository, the job will automatically be run on each push to a specific branch in the Github repository. There are cases where it is useful to run jobs on each push to your `main` branch: for example, running a schema migration script so that your data schema is always up to date. However, if you do not want the job to run frequently, you should create a branch that you push to only when you want the job to be re-run. 
+When you set up a one-time job to deploy from a Github repository, the job will **not** run automatically -- the Github action will simply update the image used to run the job. 
 
-> 🚧
-> 
-> **Note:** we are working on a better solution for deploying jobs from a Github repository, so that the job only rebuilds when you want it to. This will be addressed in the next release.
+To get the Github action to run the job automatically, you can use [this Github action](https://github.com/porter-dev/porter-run-job-action). For example:
+
+```yaml
+# ... the rest of your Github action
+    - name: Run Porter job
+      uses: porter-dev/porter-run-job-action@v0.1.0
+      with:
+        job: <job-name> # TODO: replace w/ name of your job
+        cluster: <cluster-id> # TODO: replace w/ cluster ID
+        project: <project-id> # TODO: replace w/ project ID
+        token: ${{ secrets.PORTER_TOKEN_12 }}
+```
 
 # Deploying a Cron Job
 

+ 16 - 0
internal/adapter/gorm.go

@@ -2,6 +2,9 @@ package adapter
 
 import (
 	"fmt"
+	"gorm.io/gorm/logger"
+	"log"
+	"os"
 	"time"
 
 	"github.com/porter-dev/porter/internal/config"
@@ -12,12 +15,22 @@ import (
 
 // New returns a new gorm database instance
 func New(conf *config.DBConf) (*gorm.DB, error) {
+	logger := logger.New(
+		log.New(os.Stdout, "\r\n", log.LstdFlags),
+		logger.Config{
+			SlowThreshold:              time.Second,
+			LogLevel:                   logger.Silent,
+			Colorful:                  false,
+		},
+	)
+
 	if conf.SQLLite {
 		// we add DisableForeignKeyConstraintWhenMigrating since our sqlite does
 		// not support foreign key constraints
 		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{
 			DisableForeignKeyConstraintWhenMigrating: true,
 			FullSaveAssociations:                     true,
+			Logger: logger,
 		})
 	}
 
@@ -41,6 +54,7 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 
 	defaultDB, err := gorm.Open(postgres.Open(postgresDSN), &gorm.Config{
 		FullSaveAssociations: true,
+		Logger: logger,
 	})
 
 	// attempt to create the database
@@ -51,6 +65,7 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 	// open the database connection
 	res, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
 		FullSaveAssociations: true,
+		Logger: logger,
 	})
 
 	// retry the connection 3 times
@@ -62,6 +77,7 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 			time.Sleep(timeout)
 			res, err = gorm.Open(postgres.Open(targetDSN), &gorm.Config{
 				FullSaveAssociations: true,
+				Logger: logger,
 			})
 
 			if retryCount > 3 {

+ 16 - 15
internal/helm/postrenderer.go

@@ -10,7 +10,6 @@ import (
 	"github.com/aws/aws-sdk-go/aws/arn"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/oauth2"
 	"gopkg.in/yaml.v2"
@@ -403,25 +402,27 @@ func (d *DockerSecretsPostRenderer) isRegistryNative(regName string) bool {
 	isNative := false
 
 	if strings.Contains(regName, "gcr") && d.Cluster.AuthMechanism == models.GCP {
-		// get the project id of the cluster
-		gcpInt, err := d.Repo.GCPIntegration.ReadGCPIntegration(d.Cluster.GCPIntegrationID)
+		// TODO (POR-33): fix architecture for clusters and re-add the code below
 
-		if err != nil {
-			return false
-		}
+		// // get the project id of the cluster
+		// gcpInt, err := d.Repo.GCPIntegration.ReadGCPIntegration(d.Cluster.GCPIntegrationID)
 
-		gkeProjectID, err := integrations.GCPProjectIDFromJSON(gcpInt.GCPKeyData)
+		// if err != nil {
+		// 	return false
+		// }
 
-		if err != nil {
-			return false
-		}
+		// gkeProjectID, err := integrations.GCPProjectIDFromJSON(gcpInt.GCPKeyData)
 
-		// parse the project id of the gcr url
-		if regNameArr := strings.Split(regName, "/"); len(regNameArr) >= 2 {
-			gcrProjectID := regNameArr[1]
+		// if err != nil {
+		// 	return false
+		// }
 
-			isNative = gcrProjectID == gkeProjectID
-		}
+		// // parse the project id of the gcr url
+		// if regNameArr := strings.Split(regName, "/"); len(regNameArr) >= 2 {
+		// 	gcrProjectID := regNameArr[1]
+
+		// 	isNative = gcrProjectID == gkeProjectID
+		// }
 	} else if strings.Contains(regName, "ecr") && d.Cluster.AuthMechanism == models.AWS {
 		matches := ecrPattern.FindStringSubmatch(regName)
 

+ 258 - 180
internal/kubernetes/agent.go

@@ -11,6 +11,7 @@ import (
 	"io"
 	"io/ioutil"
 	"strings"
+	"time"
 
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
@@ -28,6 +29,7 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/oauth2"
 
+	errors2 "errors"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	appsv1 "k8s.io/api/apps/v1"
@@ -69,6 +71,31 @@ type ListOptions struct {
 	FieldSelector string
 }
 
+type AuthError struct{}
+
+func (e *AuthError) Error() string {
+	return "Unauthorized error"
+}
+
+// UpdateClientset updates the Agent's Clientset (this refreshes auth tokens)
+func (a *Agent) UpdateClientset() error {
+	restConf, err := a.RESTClientGetter.ToRESTConfig()
+
+	if err != nil {
+		return err
+	}
+
+	clientset, err := kubernetes.NewForConfig(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	a.Clientset = clientset
+
+	return nil
+}
+
 // CreateConfigMap creates the configmap given the key-value pairs and namespace
 func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
@@ -535,104 +562,144 @@ func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
 	})
 }
 
+// RunWebsocketTask will run a websocket task. If the websocket returns an anauthorized error, it will restart
+// the task some number of times until failing
+func (a *Agent) RunWebsocketTask(task func() error) error {
+
+	lastTime := int64(0)
+
+	for {
+		if err := a.UpdateClientset(); err != nil {
+			return err
+		}
+
+		err := task()
+
+		if err == nil {
+			return nil
+		}
+
+		if !errors2.Is(err, &AuthError{}) {
+			return err
+		}
+
+		if time.Now().Unix()-lastTime < 60 { // don't regenerate connection if too many unauthorized errors
+			return err
+		}
+
+		lastTime = time.Now().Unix()
+	}
+}
+
 // StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
 // TODO: Support Jobs
 func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, selectors string) error {
-	// selectors is an array of max length 1. StreamControllerStatus accepts calls without the selectors argument.
-	// selectors argument is a single string with comma separated key=value pairs. (e.g. "app=porter,porter=true")
-	tweakListOptionsFunc := func(options *metav1.ListOptions) {
-		options.LabelSelector = selectors
-	}
 
-	factory := informers.NewSharedInformerFactoryWithOptions(
-		a.Clientset,
-		0,
-		informers.WithTweakListOptions(tweakListOptionsFunc),
-	)
+	run := func() error {
+		// selectors is an array of max length 1. StreamControllerStatus accepts calls without the selectors argument.
+		// selectors argument is a single string with comma separated key=value pairs. (e.g. "app=porter,porter=true")
+		tweakListOptionsFunc := func(options *metav1.ListOptions) {
+			options.LabelSelector = selectors
+		}
 
-	var informer cache.SharedInformer
-
-	// Spins up an informer depending on kind. Convert to lowercase for robustness
-	switch strings.ToLower(kind) {
-	case "deployment":
-		informer = factory.Apps().V1().Deployments().Informer()
-	case "statefulset":
-		informer = factory.Apps().V1().StatefulSets().Informer()
-	case "replicaset":
-		informer = factory.Apps().V1().ReplicaSets().Informer()
-	case "daemonset":
-		informer = factory.Apps().V1().DaemonSets().Informer()
-	case "job":
-		informer = factory.Batch().V1().Jobs().Informer()
-	case "cronjob":
-		informer = factory.Batch().V1beta1().CronJobs().Informer()
-	case "namespace":
-		informer = factory.Core().V1().Namespaces().Informer()
-	case "pod":
-		informer = factory.Core().V1().Pods().Informer()
-	}
+		factory := informers.NewSharedInformerFactoryWithOptions(
+			a.Clientset,
+			0,
+			informers.WithTweakListOptions(tweakListOptionsFunc),
+		)
 
-	stopper := make(chan struct{})
-	errorchan := make(chan error)
-	defer close(stopper)
-
-	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
-		UpdateFunc: func(oldObj, newObj interface{}) {
-			msg := Message{
-				EventType: "UPDATE",
-				Object:    newObj,
-				Kind:      strings.ToLower(kind),
-			}
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		AddFunc: func(obj interface{}) {
-			msg := Message{
-				EventType: "ADD",
-				Object:    obj,
-				Kind:      strings.ToLower(kind),
-			}
+		var informer cache.SharedInformer
+
+		// Spins up an informer depending on kind. Convert to lowercase for robustness
+		switch strings.ToLower(kind) {
+		case "deployment":
+			informer = factory.Apps().V1().Deployments().Informer()
+		case "statefulset":
+			informer = factory.Apps().V1().StatefulSets().Informer()
+		case "replicaset":
+			informer = factory.Apps().V1().ReplicaSets().Informer()
+		case "daemonset":
+			informer = factory.Apps().V1().DaemonSets().Informer()
+		case "job":
+			informer = factory.Batch().V1().Jobs().Informer()
+		case "cronjob":
+			informer = factory.Batch().V1beta1().CronJobs().Informer()
+		case "namespace":
+			informer = factory.Core().V1().Namespaces().Informer()
+		case "pod":
+			informer = factory.Core().V1().Pods().Informer()
+		}
 
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		DeleteFunc: func(obj interface{}) {
-			msg := Message{
-				EventType: "DELETE",
-				Object:    obj,
-				Kind:      strings.ToLower(kind),
-			}
+		stopper := make(chan struct{})
+		errorchan := make(chan error)
+		defer close(stopper)
 
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
+		informer.SetWatchErrorHandler(func(r *cache.Reflector, err error) {
+			if strings.HasSuffix(err.Error(), ": Unauthorized") {
+				errorchan <- &AuthError{}
 			}
-		},
-	})
+		})
+
+		informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+			UpdateFunc: func(oldObj, newObj interface{}) {
+				msg := Message{
+					EventType: "UPDATE",
+					Object:    newObj,
+					Kind:      strings.ToLower(kind),
+				}
+				if writeErr := conn.WriteJSON(msg); writeErr != nil {
+					errorchan <- writeErr
+					return
+				}
+			},
+			AddFunc: func(obj interface{}) {
+				msg := Message{
+					EventType: "ADD",
+					Object:    obj,
+					Kind:      strings.ToLower(kind),
+				}
 
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				conn.Close()
-				errorchan <- nil
-				return
+				if writeErr := conn.WriteJSON(msg); writeErr != nil {
+					errorchan <- writeErr
+					return
+				}
+			},
+			DeleteFunc: func(obj interface{}) {
+				msg := Message{
+					EventType: "DELETE",
+					Object:    obj,
+					Kind:      strings.ToLower(kind),
+				}
+
+				if writeErr := conn.WriteJSON(msg); writeErr != nil {
+					errorchan <- writeErr
+					return
+				}
+			},
+		})
+
+		go func() {
+			// listens for websocket closing handshake
+			for {
+				if _, _, err := conn.ReadMessage(); err != nil {
+					conn.Close()
+					errorchan <- nil
+					return
+				}
 			}
-		}
-	}()
+		}()
 
-	go informer.Run(stopper)
+		go informer.Run(stopper)
 
-	for {
-		select {
-		case err := <-errorchan:
-			return err
+		for {
+			select {
+			case err := <-errorchan:
+				return err
+			}
 		}
 	}
+
+	return a.RunWebsocketTask(run)
 }
 
 var b64 = base64.StdEncoding
@@ -705,132 +772,143 @@ func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Relea
 }
 
 func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chartList []string, selectors string) error {
-	tweakListOptionsFunc := func(options *metav1.ListOptions) {
-		options.LabelSelector = selectors
-	}
 
-	factory := informers.NewSharedInformerFactoryWithOptions(
-		a.Clientset,
-		0,
-		informers.WithTweakListOptions(tweakListOptionsFunc),
-		informers.WithNamespace(namespace),
-	)
+	run := func() error {
+		tweakListOptionsFunc := func(options *metav1.ListOptions) {
+			options.LabelSelector = selectors
+		}
 
-	informer := factory.Core().V1().Secrets().Informer()
+		factory := informers.NewSharedInformerFactoryWithOptions(
+			a.Clientset,
+			0,
+			informers.WithTweakListOptions(tweakListOptionsFunc),
+			informers.WithNamespace(namespace),
+		)
 
-	stopper := make(chan struct{})
-	errorchan := make(chan error)
-	defer close(stopper)
+		informer := factory.Core().V1().Secrets().Informer()
 
-	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
-		UpdateFunc: func(oldObj, newObj interface{}) {
-			secretObj, ok := newObj.(*v1.Secret)
+		stopper := make(chan struct{})
+		errorchan := make(chan error)
+		defer close(stopper)
 
-			if !ok {
-				errorchan <- fmt.Errorf("could not cast to secret")
-				return
+		informer.SetWatchErrorHandler(func(r *cache.Reflector, err error) {
+			if strings.HasSuffix(err.Error(), ": Unauthorized") {
+				errorchan <- &AuthError{}
 			}
+		})
 
-			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+		informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+			UpdateFunc: func(oldObj, newObj interface{}) {
+				secretObj, ok := newObj.(*v1.Secret)
 
-			if isNotHelmRelease && err == nil {
-				return
-			}
+				if !ok {
+					errorchan <- fmt.Errorf("could not cast to secret")
+					return
+				}
 
-			if err != nil {
-				errorchan <- err
-				return
-			}
+				helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
 
-			msg := Message{
-				EventType: "UPDATE",
-				Object:    helm_object,
-			}
+				if isNotHelmRelease && err == nil {
+					return
+				}
 
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		AddFunc: func(obj interface{}) {
-			secretObj, ok := obj.(*v1.Secret)
+				if err != nil {
+					errorchan <- err
+					return
+				}
 
-			if !ok {
-				errorchan <- fmt.Errorf("could not cast to secret")
-				return
-			}
+				msg := Message{
+					EventType: "UPDATE",
+					Object:    helm_object,
+				}
 
-			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+				if writeErr := conn.WriteJSON(msg); writeErr != nil {
+					errorchan <- writeErr
+					return
+				}
+			},
+			AddFunc: func(obj interface{}) {
+				secretObj, ok := obj.(*v1.Secret)
 
-			if isNotHelmRelease && err == nil {
-				return
-			}
+				if !ok {
+					errorchan <- fmt.Errorf("could not cast to secret")
+					return
+				}
 
-			if err != nil {
-				errorchan <- err
-				return
-			}
+				helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
 
-			msg := Message{
-				EventType: "ADD",
-				Object:    helm_object,
-			}
+				if isNotHelmRelease && err == nil {
+					return
+				}
 
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		DeleteFunc: func(obj interface{}) {
-			secretObj, ok := obj.(*v1.Secret)
+				if err != nil {
+					errorchan <- err
+					return
+				}
 
-			if !ok {
-				errorchan <- fmt.Errorf("could not cast to secret")
-				return
-			}
+				msg := Message{
+					EventType: "ADD",
+					Object:    helm_object,
+				}
 
-			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+				if writeErr := conn.WriteJSON(msg); writeErr != nil {
+					errorchan <- writeErr
+					return
+				}
+			},
+			DeleteFunc: func(obj interface{}) {
+				secretObj, ok := obj.(*v1.Secret)
 
-			if isNotHelmRelease && err == nil {
-				return
-			}
+				if !ok {
+					errorchan <- fmt.Errorf("could not cast to secret")
+					return
+				}
 
-			if err != nil {
-				errorchan <- err
-				return
-			}
+				helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
 
-			msg := Message{
-				EventType: "DELETE",
-				Object:    helm_object,
-			}
+				if isNotHelmRelease && err == nil {
+					return
+				}
 
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-	})
+				if err != nil {
+					errorchan <- err
+					return
+				}
 
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				conn.Close()
-				errorchan <- nil
-				return
+				msg := Message{
+					EventType: "DELETE",
+					Object:    helm_object,
+				}
+
+				if writeErr := conn.WriteJSON(msg); writeErr != nil {
+					errorchan <- writeErr
+					return
+				}
+			},
+		})
+
+		go func() {
+			// listens for websocket closing handshake
+			for {
+				if _, _, err := conn.ReadMessage(); err != nil {
+					conn.Close()
+					errorchan <- nil
+					return
+				}
 			}
-		}
-	}()
+		}()
 
-	go informer.Run(stopper)
+		go informer.Run(stopper)
 
-	for {
-		select {
-		case err := <-errorchan:
-			return err
+		for {
+			select {
+			case err := <-errorchan:
+				return err
+			}
 		}
 	}
+
+	return a.RunWebsocketTask(run)
 }
 
 // ProvisionECR spawns a new provisioning pod that creates an ECR instance

+ 2 - 1
internal/models/cluster.go

@@ -62,7 +62,8 @@ type Cluster struct {
 	DOIntegrationID   uint
 
 	// A token cache that can be used by an auth mechanism, if desired
-	TokenCache integrations.ClusterTokenCache `json:"token_cache"`
+	TokenCache   integrations.ClusterTokenCache `json:"token_cache" gorm:"-" sql:"-"`
+	TokenCacheID uint                           `gorm:"token_cache_id"`
 
 	// CertificateAuthorityData for the cluster, encrypted at rest
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`

+ 39 - 23
internal/repository/gorm/cluster.go

@@ -2,7 +2,6 @@ package gorm
 
 import (
 	"context"
-
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
@@ -144,13 +143,15 @@ func (repo *ClusterRepository) CreateCluster(
 	}
 
 	// create a token cache by default
-	assoc = ctxDB.Model(cluster).Association("TokenCache")
+	cluster.TokenCache.ClusterID = cluster.ID
 
-	if assoc.Error != nil {
-		return nil, assoc.Error
+	if err := ctxDB.Create(&cluster.TokenCache).Error; err != nil {
+		return nil, err
 	}
 
-	if err := assoc.Append(&cluster.TokenCache); err != nil {
+	cluster.TokenCacheID = cluster.TokenCache.ID
+
+	if err := ctxDB.Save(cluster).Error; err != nil {
 		return nil, err
 	}
 
@@ -172,10 +173,20 @@ func (repo *ClusterRepository) ReadCluster(
 	cluster := &models.Cluster{}
 
 	// preload Clusters association
-	if err := ctxDB.Preload("TokenCache").Where("id = ?", id).First(&cluster).Error; err != nil {
+	if err := ctxDB.Debug().Where("id = ?", id).First(&cluster).Error; err != nil {
 		return nil, err
 	}
 
+	cache := ints.ClusterTokenCache{}
+
+	if cluster.TokenCacheID != 0 {
+		if err := ctxDB.Where("id = ?", cluster.TokenCacheID).First(&cache).Error; err != nil {
+			return nil, err
+		}
+	}
+
+	cluster.TokenCache = cache
+
 	err := repo.DecryptClusterData(cluster, repo.key)
 
 	if err != nil {
@@ -252,17 +263,29 @@ func (repo *ClusterRepository) UpdateClusterTokenCache(
 		return nil, err
 	}
 
-	// delete the existing token cache first
-	if err := ctxDB.Where("id = ?", tokenCache.ID).Unscoped().Delete(&cluster.TokenCache).Error; err != nil {
-		return nil, err
-	}
+	if cluster.TokenCacheID == 0 {
+		tokenCache.ClusterID = cluster.ID
+		if err := ctxDB.Create(tokenCache).Error; err != nil {
+			return nil, err
+		}
+		cluster.TokenCacheID = tokenCache.ID
+		if err := ctxDB.Save(cluster).Error; err != nil {
+			return nil, err
+		}
+	} else {
+		prev := &ints.ClusterTokenCache{}
+
+		if err := ctxDB.Where("id = ?", cluster.TokenCacheID).First(prev).Error; err != nil {
+			return nil, err
+		}
 
-	// set the new token cache
-	cluster.TokenCache.Token = tokenCache.Token
-	cluster.TokenCache.Expiry = tokenCache.Expiry
+		prev.Token = tokenCache.Token
+		prev.Expiry = tokenCache.Expiry
+		prev.ClusterID = cluster.ID
 
-	if err := ctxDB.Save(cluster).Error; err != nil {
-		return nil, err
+		if err := ctxDB.Save(prev).Error; err != nil {
+			return nil, err
+		}
 	}
 
 	return cluster, nil
@@ -273,16 +296,9 @@ func (repo *ClusterRepository) DeleteCluster(
 	cluster *models.Cluster,
 ) error {
 	// clear TokenCache association
-	assoc := repo.db.Model(cluster).Association("TokenCache")
-
-	if assoc.Error != nil {
-		return assoc.Error
-	}
-
-	if err := assoc.Clear(); err != nil {
+	if err := repo.db.Where("id = ?", cluster.TokenCacheID).Delete(&ints.TokenCache{}).Error; err != nil {
 		return err
 	}
-
 	if err := repo.db.Where("id = ?", cluster.ID).Delete(&models.Cluster{}).Error; err != nil {
 		return err
 	}

+ 6 - 1
server/api/cluster_handler.go

@@ -98,7 +98,12 @@ func (app *App) HandleReadProjectCluster(w http.ResponseWriter, r *http.Request)
 	if app.ServerConf.IsTesting {
 		agent = app.TestAgents.K8sAgent
 	} else {
-		agent, _ = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
 	}
 
 	endpoint, found, ingressErr := domain.GetNGINXIngressServiceIP(agent.Clientset)

+ 24 - 26
server/api/k8s_handler.go

@@ -3,10 +3,6 @@ package api
 import (
 	"encoding/json"
 	"fmt"
-	"net/http"
-	"net/url"
-	"strconv"
-
 	"github.com/go-chi/chi"
 	"github.com/gorilla/schema"
 	"github.com/gorilla/websocket"
@@ -16,6 +12,9 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/tools/clientcmd"
+	"net/http"
+	"net/url"
+	"strconv"
 )
 
 // Enumeration of k8s API error codes, represented as int64
@@ -1134,13 +1133,12 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	// create a new agent
-	var agent *kubernetes.Agent
+	// get path parameters
+	kind := chi.URLParam(r, "kind")
 
-	if app.ServerConf.IsTesting {
-		agent = app.TestAgents.K8sAgent
-	} else {
-		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	selectors := ""
+	if vals["selectors"] != nil {
+		selectors = vals["selectors"][0]
 	}
 
 	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
@@ -1152,18 +1150,19 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 		app.handleErrorUpgradeWebsocket(err, w)
 	}
 
-	// get path parameters
-	kind := chi.URLParam(r, "kind")
+	// create a new agent
+	var agent *kubernetes.Agent
 
-	selectors := ""
-	if vals["selectors"] != nil {
-		selectors = vals["selectors"][0]
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
+
 	err = agent.StreamControllerStatus(conn, kind, selectors)
 
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)
-		return
 	}
 }
 
@@ -1199,15 +1198,6 @@ func (app *App) HandleStreamHelmReleases(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// create a new agent
-	var agent *kubernetes.Agent
-
-	if app.ServerConf.IsTesting {
-		agent = app.TestAgents.K8sAgent
-	} else {
-		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
-	}
-
 	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
 
 	// upgrade to websocket.
@@ -1233,11 +1223,19 @@ func (app *App) HandleStreamHelmReleases(w http.ResponseWriter, r *http.Request)
 		namespace = vals["namespace"][0]
 	}
 
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
 	err = agent.StreamHelmReleases(conn, namespace, chartList, selectors)
 
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)
-		return
 	}
 }