瀏覽代碼

Merge branch 'master' of github.com:porter-dev/porter into 0.6.0-implement-rbac

jnfrati 4 年之前
父節點
當前提交
babd2a48a4

+ 8 - 3
.github/workflows/dev.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:dev
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
-          kubectl rollout restart deployment/porter
+          kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/staging.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:staging
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
             
           kubectl rollout restart deployment/porter

+ 12 - 4
dashboard/src/components/Loading.tsx

@@ -4,6 +4,8 @@ import loading from "assets/loading.gif";
 
 type PropsType = {
   offset?: string;
+  width?: string;
+  height?: string;
 };
 
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
       </StyledLoading>
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
 `;
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-top: ${(props: { offset?: string }) => props.offset};
+  margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;

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

@@ -103,7 +103,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       )
       .then((res) => {
         let image = res.data?.config?.image?.repository;
-        let tag = res.data?.config?.image?.tag.toString();
+        let tag = res.data?.config?.image?.tag?.toString();
         let newestImage = tag ? image + ":" + tag : image;
         let imageIsPlaceholder = false;
         if (

+ 233 - 239
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -1,4 +1,10 @@
-import React, { Component } from "react";
+import React, {
+  Component,
+  useState,
+  useEffect,
+  useContext,
+  useMemo,
+} from "react";
 import styled from "styled-components";
 
 import { InviteType } from "shared/types";
@@ -10,36 +16,26 @@ import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
 
-type PropsType = {};
+type Props = {};
 
-type StateType = {
-  loading: boolean;
-  invites: InviteType[];
-  email: string;
-  invalidEmail: boolean;
-  isHTTPS: boolean;
-};
-
-const dummyInvites = [];
+const InvitePage: React.FunctionComponent<Props> = ({}) => {
+  const { currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [invites, setInvites] = useState<Array<InviteType>>([]);
+  const [email, setEmail] = useState("");
+  const [isInvalidEmail, setIsInvalidEmail] = useState(false);
+  const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
-export default class InviteList extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    invites: [] as InviteType[],
-    email: "",
-    invalidEmail: false,
-    isHTTPS: window.location.protocol === "https:",
-  };
-
-  componentDidMount() {
-    this.getInviteData();
-  }
+  useEffect(() => {
+    getInviteData();
+  }, []);
 
-  getInviteData = () => {
-    let { currentProject } = this.context;
+  const getInviteData = () => {
+    setIsLoading(true);
 
-    this.setState({ loading: true });
     api
       .getInvites(
         "<token>",
@@ -48,206 +44,220 @@ export default class InviteList extends Component<PropsType, StateType> {
           id: currentProject.id,
         }
       )
-      .then((res) => this.setState({ invites: res.data, loading: false }))
+      .then((res) => {
+        setInvites(res.data);
+        setIsLoading(false);
+      })
       .catch((err) => console.log(err));
   };
 
-  validateEmail = () => {
-    var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    if (regex.test(this.state.email.toLowerCase())) {
-      this.setState({ invalidEmail: false });
-      this.createInvite();
-    } else {
-      this.setState({ invalidEmail: true });
-    }
-  };
-
-  createInvite = () => {
-    let { currentProject } = this.context;
+  const createInvite = () => {
     api
-      .createInvite(
-        "<token>",
-        { email: this.state.email },
-        { id: currentProject.id }
-      )
-      .then((_) => {
-        this.getInviteData();
-        this.setState({ email: "" });
+      .createInvite("<token>", { email }, { id: currentProject.id })
+      .then(() => {
+        getInviteData();
+        setEmail("");
       })
       .catch((err) => console.log(err));
   };
 
-  deleteInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const deleteInvite = (inviteId: number) => {
     api
       .deleteInvite(
         "<token>",
         {},
         {
           id: currentProject.id,
-          invId: this.state.invites[index].id,
+          invId: inviteId,
         }
       )
-      .then(this.getInviteData)
+      .then(getInviteData)
       .catch((err) => console.log(err));
   };
 
-  replaceInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const replaceInvite = (inviteEmail: string, inviteId: number) => {
     api
       .createInvite(
         "<token>",
-        { email: this.state.invites[index].email },
+        { email: inviteEmail },
         { id: currentProject.id }
       )
-      .then((_) =>
+      .then(() =>
         api.deleteInvite(
           "<token>",
           {},
           {
             id: currentProject.id,
-            invId: this.state.invites[index].id,
+            invId: inviteId,
           }
         )
       )
-      .then(this.getInviteData)
+      .then(getInviteData)
       .catch((err) => console.log(err));
   };
 
-  getInviteUrl = (index: number) => {
-    let { currentProject } = this.context;
-    return `${this.state.isHTTPS ? "https://" : ""}${
-      window.location.host
-    }/api/projects/${currentProject.id}/invites/${
-      this.state.invites[index].token
-    }`;
+  const validateEmail = () => {
+    const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+    if (!regex.test(email.toLowerCase())) {
+      setIsInvalidEmail(true);
+      return;
+    }
+
+    setIsInvalidEmail(false);
+    createInvite();
   };
 
-  renderInvitations = () => {
-    let { currentProject } = this.context;
-    if (this.state.loading) {
-      return <Loading />;
-    } else {
-      var invContent: any[] = [];
-      var collabList: any[] = [];
-      this.state.invites.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
-      this.state.invites.sort((a: any, b: any) =>
-        a.accepted > b.accepted ? 1 : -1
-      );
-      for (let i = 0; i < this.state.invites.length; i++) {
-        if (this.state.invites[i].accepted) {
-          collabList.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}></LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton invis={true}>Remove</CopyButton>
-              </Td>
-            </Tr>
+  const columns = useMemo<
+    Column<{
+      email: string;
+      id: number;
+      status: string;
+      invite_link: string;
+    }>[]
+  >(
+    () => [
+      {
+        Header: "Mail address",
+        accessor: "email",
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+        Cell: ({ row }) => {
+          return (
+            <Status status={row.values.status}>{row.values.status}</Status>
           );
-        } else if (this.state.invites[i].expired) {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  Link Expired.
-                  <NewLinkButton onClick={() => this.replaceInvite(i)}>
-                    <u>Generate a new link</u>
-                  </NewLinkButton>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        Header: "Invite link",
+        accessor: "invite_link",
+        Cell: ({ row }) => {
+          if (row.values.status === "expired") {
+            return (
+              <NewLinkButton
+                onClick={() => replaceInvite(row.values.email, row.values.id)}
+              >
+                <u>Generate a new link</u>
+              </NewLinkButton>
+            );
+          }
+          if (row.values.status === "accepted") {
+            return "";
+          }
+
+          return (
+            <>
+              <CopyToClipboard as={Url} text={row.values.invite_link}>
+                <span>{row.values.invite_link}</span>
+                <i className="material-icons-outlined">content_copy</i>
+              </CopyToClipboard>
+            </>
           );
-        } else {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  <ShareLink
-                    disabled={true}
-                    type="string"
-                    value={`${this.state.isHTTPS ? "https://" : ""}${
-                      window.location.host
-                    }/api/projects/${currentProject.id}/invites/${
-                      this.state.invites[i].token
-                    }`}
-                    placeholder="Unable to retrieve link"
-                  />
-                  <CopyToClipboard
-                    as={CopyButton}
-                    text={this.getInviteUrl(i)}
-                    onError={() => console.log("Couldn't copy to clipboard")}
-                  >
-                    Copy Link
-                  </CopyToClipboard>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        accessor: "id",
+        Cell: ({ row }) => {
+          if (row.values.status === "accepted") {
+            return <CopyButton invis={true}>Remove</CopyButton>;
+          }
+          return (
+            <>
+              <CopyButton onClick={() => deleteInvite(row.values.id)}>
+                Delete Invite
+              </CopyButton>
+            </>
           );
+        },
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    const inviteList = [...invites];
+    inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
+    inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
+    const buildInviteLink = (token: string) => `
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
+      currentProject.id
+    }/invites/${token}
+    `;
+
+    const mappedInviteList = inviteList.map(
+      ({ accepted, expired, token, ...rest }) => {
+        if (accepted) {
+          return {
+            status: "accepted",
+            invite_link: buildInviteLink(token),
+            ...rest,
+          };
+        }
+
+        if (!accepted && expired) {
+          return {
+            status: "expired",
+            invite_link: buildInviteLink(token),
+            ...rest,
+          };
         }
+
+        return {
+          status: "pending",
+          invite_link: buildInviteLink(token),
+          ...rest,
+        };
       }
+    );
 
-      return (
-        <>
-          <Heading>Invites & Collaborators</Heading>
-          <Helper>Manage pending invites and view collaborators.</Helper>
-          {invContent.length > 0 || collabList.length > 0 ? (
-            <Table>
-              <tbody>
-                {invContent}
-                {collabList}
-              </tbody>
-            </Table>
-          ) : (
-            <Placeholder>
-              This project currently has no invites or collaborators.
-            </Placeholder>
-          )}
-        </>
-      );
-    }
-  };
+    return mappedInviteList || [];
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS]);
 
-  render() {
-    return (
-      <>
-        <Heading isAtTop={true}>Share Project</Heading>
-        <Helper>Generate a project invite for another admin user.</Helper>
-        <DarkMatter />
+  return (
+    <>
+      <Heading isAtTop={true}>Share Project</Heading>
+      <Helper>Generate a project invite for another admin user.</Helper>
+      <InputRowWrapper>
         <InputRow
-          value={this.state.email}
+          value={email}
           type="text"
-          setValue={(x: string) => this.setState({ email: x })}
-          width="calc(100%)"
+          setValue={(newEmail: string) => setEmail(newEmail)}
+          width="100%"
           placeholder="ex: mrp@getporter.dev"
         />
-        <ButtonWrapper>
-          <InviteButton disabled={false} onClick={() => this.validateEmail()}>
-            Create Invite
-          </InviteButton>
-          {this.state.invalidEmail && (
-            <Invalid>Invalid email address. Please try again.</Invalid>
-          )}
-        </ButtonWrapper>
-        {this.renderInvitations()}
-      </>
-    );
-  }
-}
+      </InputRowWrapper>
+      <ButtonWrapper>
+        <InviteButton disabled={false} onClick={() => validateEmail()}>
+          Create Invite
+        </InviteButton>
+        {isInvalidEmail && (
+          <Invalid>Invalid email address. Please try again.</Invalid>
+        )}
+      </ButtonWrapper>
+
+      <Heading>Invites & Collaborators</Heading>
+      <Helper>Manage pending invites and view collaborators.</Helper>
+      {isLoading && <Loading height={"30%"} />}
+      {data?.length && !isLoading ? (
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={false}
+          disableGlobalFilter={true}
+        />
+      ) : (
+        !isLoading && (
+          <Placeholder>
+            This project currently has no invites or collaborators.
+          </Placeholder>
+        )
+      )}
+    </>
+  );
+};
 
-InviteList.contextType = Context;
+export default InvitePage;
 
 const Placeholder = styled.div`
   width: 100%;
@@ -267,9 +277,8 @@ const ButtonWrapper = styled.div`
   align-items: center;
 `;
 
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -10px;
+const InputRowWrapper = styled.div`
+  width: 40%;
 `;
 
 const CopyButton = styled.div`
@@ -278,16 +287,17 @@ const CopyButton = styled.div`
   color: #ffffff;
   font-weight: 400;
   font-size: 13px;
-  margin-left: 12px;
+  margin: 8px 0 8px 12px;
   float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
   width: 120px;
-  padding-top: 7px;
-  padding-bottom: 6px;
   cursor: pointer;
+  height: 30px;
   border-radius: 5px;
   border: 1px solid #ffffff20;
   background-color: #ffffff10;
-  text-align: center;
   overflow: hidden;
   transition: all 0.1s ease-out;
   :hover {
@@ -299,6 +309,9 @@ const CopyButton = styled.div`
 const NewLinkButton = styled(CopyButton)`
   border: none;
   width: auto;
+  float: none;
+  display: block;
+  margin: unset;
   background-color: transparent;
   :hover {
     border: none;
@@ -336,67 +349,28 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   margin-bottom: 10px;
 `;
 
-const Rower = styled.div`
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
   display: flex;
-  flex-direction: row;
   align-items: center;
-`;
-
-const ShareLink = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 60%;
-  color: #74a5f7;
-  margin-left: -10px;
-  padding: 5px 10px;
-  height: 30px;
-  text-overflow: ellipsis;
-  border-radius: 3px;
-  ::placeholder,
-  ::-webkit-input-placeholder {
-    color: #fa0a26;
-    font-weight: 600;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
   }
-`;
-
-const Table = styled.table`
-  width: 100%;
-  border-spacing: 0px;
-  border: 1px solid #ffffff55;
-  margin-top: 22px;
-  border-radius: 5px;
-  background: #ffffff11;
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-`;
 
-const Td = styled.td`
-  white-space: nowrap;
-  padding: 6px 0px;
-  border-top: ${(props: { isTop: boolean }) =>
-    props.isTop ? "none" : "1px solid #ffffff55"};
-  &:last-child {
-    padding-right: 16px;
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
-`;
-
-const Tr = styled.tr``;
-
-const MailTd = styled(Td)`
-  padding: 0 12px;
-  max-width: 186px;
-  min-width: 186px;
-  overflow: hidden;
-  color: #aaaabb;
-  text-overflow: ellipsis;
-`;
 
-const LinkTd = styled(Td)`
-  width: calc(100% - 40px);
-  padding-left: 40px;
+  :hover {
+    cursor: pointer;
+  }
 `;
 
 const Invalid = styled.div`
@@ -405,3 +379,23 @@ const Invalid = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
 `;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;

+ 2 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 
-import InviteList from "./InviteList";
+import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
@@ -33,7 +33,7 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
 
   renderTabContents = () => {
     if (this.state.currentTab === "manage-access") {
-      return <InviteList />;
+      return <InvitePage />;
     } else {
       return (
         <>

+ 1 - 0
docker-compose.dev.yaml

@@ -24,6 +24,7 @@ services:
       - ./cmd:/porter/cmd
       - ./internal:/porter/internal
       - ./server:/porter/server
+      - ./docker/kubeconfig.yaml:/porter/kubeconfig.yaml
   postgres:
     image: postgres:latest
     container_name: postgres

+ 1 - 1
internal/auth/sessionstore/sessionstore.go

@@ -124,7 +124,7 @@ func NewStore(repo *repository.Repository, conf config.ServerConf) (*PGStore, er
 			MaxAge:   86400 * 30,
 			Secure:   true,
 			HttpOnly: true,
-			SameSite: http.SameSiteStrictMode,
+			SameSite: http.SameSiteLaxMode,
 		},
 		Repo: repo,
 	}

+ 9 - 4
internal/config/config.go

@@ -52,10 +52,15 @@ type ServerConf struct {
 	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
 	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
 
-	DOClientID          string `env:"DO_CLIENT_ID"`
-	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
-	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`
-	SegmentClientKey    string `env:"SEGMENT_CLIENT_KEY"`
+	DOClientID                 string `env:"DO_CLIENT_ID"`
+	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
+	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
+	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
+	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
+
+	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
+	IngressCluster     string `env:"INGRESS_CLUSTER"`
+	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 14 - 0
internal/kubernetes/agent.go

@@ -834,6 +834,7 @@ func (a *Agent) ProvisionECR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -844,6 +845,7 @@ func (a *Agent) ProvisionECR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -869,6 +871,7 @@ func (a *Agent) ProvisionEKS(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -879,6 +882,7 @@ func (a *Agent) ProvisionEKS(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -904,6 +908,7 @@ func (a *Agent) ProvisionGCR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -914,6 +919,7 @@ func (a *Agent) ProvisionGCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
@@ -936,6 +942,7 @@ func (a *Agent) ProvisionGKE(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -946,6 +953,7 @@ func (a *Agent) ProvisionGKE(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
@@ -972,6 +980,7 @@ func (a *Agent) ProvisionDOCR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -997,6 +1006,7 @@ func (a *Agent) ProvisionDOCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		DO: &do.Conf{
 			DOToken: tok,
@@ -1022,6 +1032,7 @@ func (a *Agent) ProvisionDOKS(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -1048,6 +1059,7 @@ func (a *Agent) ProvisionDOKS(
 		Postgres:            pgConf,
 		LastApplied:         infra.LastApplied,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		DO: &do.Conf{
 			DOToken: tok,
 		},
@@ -1069,6 +1081,7 @@ func (a *Agent) ProvisionTest(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 
@@ -1080,6 +1093,7 @@ func (a *Agent) ProvisionTest(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 	}
 
 	return a.provision(prov, infra, repo)

+ 3 - 3
internal/kubernetes/config.go

@@ -85,7 +85,7 @@ func GetAgentInClusterConfig() (*Agent, error) {
 		return nil, err
 	}
 
-	restClientGetter := newRESTClientGetterFromInClusterConfig(conf)
+	restClientGetter := NewRESTClientGetterFromInClusterConfig(conf)
 	clientset, err := kubernetes.NewForConfig(conf)
 
 	return &Agent{restClientGetter, clientset}, nil
@@ -386,9 +386,9 @@ func (conf *OutOfClusterConfig) setTokenCache(token string, expiry time.Time) er
 	return err
 }
 
-// newRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
+// NewRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
 // default values set from the *rest.Config
-func newRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
+func NewRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
 	cfs := genericclioptions.NewConfigFlags(false)
 
 	cfs.ClusterName = &conf.ServerName

+ 31 - 0
internal/kubernetes/local/kubeconfig.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 
+	k8s "k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -58,6 +59,36 @@ func GetKubeconfigFromHost(kubeconfigPath string, contexts []string) ([]byte, er
 	return clientcmd.Write(strippedRawConf)
 }
 
+// GetSelfAgentFromFileConfig reads a kubeconfig from a local file and generates an
+// Agent from that kubeconfig
+func GetSelfAgentFromFileConfig(kubeconfigPath string) (*kubernetes.Agent, error) {
+	configBytes, err := GetKubeconfigFromHost(kubeconfigPath, []string{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	cmdConf, err := clientcmd.NewClientConfigFromBytes(configBytes)
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restClientGetter := kubernetes.NewRESTClientGetterFromInClusterConfig(restConf)
+	clientset, err := k8s.NewForConfig(restConf)
+
+	return &kubernetes.Agent{
+		RESTClientGetter: restClientGetter,
+		Clientset:        clientset,
+	}, nil
+}
+
 // ResolveKubeconfigPath finds the path to a kubeconfig, first searching for the
 // passed string, then in the home directory, then as an env variable.
 func ResolveKubeconfigPath(kubeconfigPath string) (string, error) {

+ 11 - 24
internal/kubernetes/provisioner/provisioner.go

@@ -45,6 +45,7 @@ type Conf struct {
 	Postgres            *config.DBConf
 	Operation           ProvisionerOperation
 	ProvisionerImageTag string
+	ImagePullSecret     string
 	LastApplied         []byte
 
 	// provider-specific configurations
@@ -290,6 +291,14 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 		env = conf.DOKS.AttachDOKSEnv(env)
 	}
 
+	imagePullSecrets := []v1.LocalObjectReference{}
+
+	if conf.ImagePullSecret != "" {
+		imagePullSecrets = append(imagePullSecrets, v1.LocalObjectReference{
+			Name: conf.ImagePullSecret,
+		})
+	}
+
 	return &batchv1.Job{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      conf.Name,
@@ -304,7 +313,8 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 					Labels: labels,
 				},
 				Spec: v1.PodSpec{
-					RestartPolicy: v1.RestartPolicyNever,
+					RestartPolicy:    v1.RestartPolicyNever,
+					ImagePullSecrets: imagePullSecrets,
 					Containers: []v1.Container{
 						{
 							Name:            "provisioner",
@@ -312,24 +322,6 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 							ImagePullPolicy: v1.PullAlways,
 							Args:            args,
 							Env:             env,
-							VolumeMounts: []v1.VolumeMount{
-								v1.VolumeMount{
-									MountPath: "/.terraform/plugin-cache",
-									Name:      "tf-cache",
-									ReadOnly:  true,
-								},
-							},
-						},
-					},
-					Volumes: []v1.Volume{
-						v1.Volume{
-							Name: "tf-cache",
-							VolumeSource: v1.VolumeSource{
-								PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{
-									ClaimName: "tf-cache-pvc",
-									ReadOnly:  true,
-								},
-							},
 						},
 					},
 				},
@@ -431,11 +423,6 @@ func (conf *Conf) addTFEnv(env []v1.EnvVar) []v1.EnvVar {
 		Value: "./terraform",
 	})
 
-	env = append(env, v1.EnvVar{
-		Name:  "TF_PLUGIN_CACHE_DIR",
-		Value: "/.terraform/plugin-cache",
-	})
-
 	env = append(env, v1.EnvVar{
 		Name:  "TF_PORTER_BACKEND",
 		Value: "postgres",

+ 63 - 15
server/api/api.go

@@ -10,6 +10,7 @@ import (
 	vr "github.com/go-playground/validator/v10"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
@@ -67,7 +68,8 @@ type App struct {
 	TestAgents *TestAgents
 
 	// An in-cluster agent if service is running in cluster
-	InClusterAgent *kubernetes.Agent
+	ProvisionerAgent *kubernetes.Agent
+	IngressAgent     *kubernetes.Agent
 
 	// redis client for redis connection
 	RedisConf *config.RedisConf
@@ -140,22 +142,12 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app.Store = store
-
-	// if application is running in-cluster, set provisioning capabilities
-	if kubernetes.IsInCluster() {
-		app.Capabilities.Provisioning = true
-
-		agent, err := kubernetes.GetAgentInClusterConfig()
-
-		if err != nil {
-			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
-		}
-
-		app.InClusterAgent = agent
-	}
-
 	sc := conf.ServerConf
 
+	// get the InClusterAgent from either a file-based kubeconfig or the in-cluster agent
+	app.assignProvisionerAgent(&sc)
+	app.assignIngressAgent(&sc)
+
 	// if server config contains OAuth client info, create clients
 	if sc.GithubClientID != "" && sc.GithubClientSecret != "" {
 		app.Capabilities.Github = true
@@ -217,6 +209,62 @@ func New(conf *AppConfig) (*App, error) {
 	return app, nil
 }
 
+func (app *App) assignProvisionerAgent(sc *config.ServerConf) error {
+	if sc.ProvisionerCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
+		app.Capabilities.Provisioning = true
+
+		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
+
+		if err != nil {
+			return fmt.Errorf("could not get in-cluster agent: %v", err)
+		}
+
+		app.ProvisionerAgent = agent
+
+		return nil
+	} else if sc.ProvisionerCluster == "kubeconfig" {
+		return fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
+	}
+
+	app.Capabilities.Provisioning = true
+
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		return fmt.Errorf("could not get in-cluster agent: %v", err)
+	}
+
+	app.ProvisionerAgent = agent
+
+	return nil
+}
+
+func (app *App) assignIngressAgent(sc *config.ServerConf) error {
+	if sc.IngressCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
+		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
+
+		if err != nil {
+			return fmt.Errorf("could not get in-cluster agent: %v", err)
+		}
+
+		app.IngressAgent = agent
+
+		return nil
+	} else if sc.IngressCluster == "kubeconfig" {
+		return fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
+	}
+
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		return fmt.Errorf("could not get in-cluster agent: %v", err)
+	}
+
+	app.IngressAgent = agent
+
+	return nil
+}
+
 func (app *App) getTokenFromRequest(r *http.Request) *token.Token {
 	reqToken := r.Header.Get("Authorization")
 

+ 1 - 1
server/api/dns_record_handler.go

@@ -76,7 +76,7 @@ func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
 
 	_record := domain.DNSRecord(*record)
 
-	err = _record.CreateDomain(app.InClusterAgent.Clientset)
+	err = _record.CreateDomain(app.IngressAgent.Clientset)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)

+ 25 - 12
server/api/provision_handler.go

@@ -51,7 +51,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionTest(
+	_, err = app.ProvisionerAgent.ProvisionTest(
 		uint(projID),
 		infra,
 		*app.Repo,
@@ -59,6 +59,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -127,6 +128,7 @@ func (app *App) HandleDestroyTestInfra(w http.ResponseWriter, r *http.Request) {
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -191,7 +193,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionECR(
+	_, err = app.ProvisionerAgent.ProvisionECR(
 		uint(projID),
 		awsInt,
 		form.ECRName,
@@ -201,6 +203,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -273,7 +276,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionECR(
+	_, err = app.ProvisionerAgent.ProvisionECR(
 		infra.ProjectID,
 		awsInt,
 		form.ECRName,
@@ -283,6 +286,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -347,7 +351,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionEKS(
+	_, err = app.ProvisionerAgent.ProvisionEKS(
 		uint(projID),
 		awsInt,
 		form.EKSName,
@@ -358,6 +362,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -430,7 +435,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionEKS(
+	_, err = app.ProvisionerAgent.ProvisionEKS(
 		infra.ProjectID,
 		awsInt,
 		form.EKSName,
@@ -441,6 +446,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -505,7 +511,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionGCR(
+	_, err = app.ProvisionerAgent.ProvisionGCR(
 		uint(projID),
 		gcpInt,
 		*app.Repo,
@@ -514,6 +520,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -588,7 +595,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionGKE(
+	_, err = app.ProvisionerAgent.ProvisionGKE(
 		uint(projID),
 		gcpInt,
 		form.GKEName,
@@ -598,6 +605,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -670,7 +678,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionGKE(
+	_, err = app.ProvisionerAgent.ProvisionGKE(
 		infra.ProjectID,
 		gcpInt,
 		form.GKEName,
@@ -680,6 +688,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -788,7 +797,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionDOCR(
+	_, err = app.ProvisionerAgent.ProvisionDOCR(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -800,6 +809,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -872,7 +882,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionDOCR(
+	_, err = app.ProvisionerAgent.ProvisionDOCR(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,
@@ -884,6 +894,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -948,7 +959,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionDOKS(
+	_, err = app.ProvisionerAgent.ProvisionDOKS(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -960,6 +971,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -1032,7 +1044,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionDOKS(
+	_, err = app.ProvisionerAgent.ProvisionDOKS(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,
@@ -1044,6 +1056,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {

+ 123 - 0
server/api/release_handler.go

@@ -582,6 +582,129 @@ func (app *App) HandleGetReleaseAllPods(w http.ResponseWriter, r *http.Request)
 	}
 }
 
+type GetJobStatusResult struct {
+	Status string `json:"status"`
+}
+
+// HandleGetJobStatus gets the status for a specific job
+func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	namespace := chi.URLParam(r, "namespace")
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+				Storage:           "secret",
+				Namespace:         namespace,
+			},
+		},
+		Name:     name,
+		Revision: 0,
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	k8sForm := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+	k8sForm.DefaultNamespace = form.ReleaseForm.Namespace
+
+	// validate the form
+	if err := app.validator.Struct(k8sForm); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new kubernetes agent
+	var k8sAgent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		k8sAgent = app.TestAgents.K8sAgent
+	} else {
+		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
+	}
+
+	jobs, err := k8sAgent.ListJobsByLabel(namespace, kubernetes.Label{
+		Key: "helm.sh/chart",
+		Val: fmt.Sprintf("%s-%s", release.Chart.Name(), release.Chart.Metadata.Version),
+	}, kubernetes.Label{
+		Key: "meta.helm.sh/release-name",
+		Val: name,
+	})
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	res := &GetJobStatusResult{
+		Status: "succeeded",
+	}
+
+	// get the most recent job
+	if len(jobs) > 0 {
+		mostRecentJob := jobs[0]
+
+		for _, job := range jobs {
+			createdAt := job.ObjectMeta.CreationTimestamp
+
+			if mostRecentJob.CreationTimestamp.Before(&createdAt) {
+				mostRecentJob = job
+			}
+		}
+
+		// get the status of the most recent job
+		if mostRecentJob.Status.Succeeded >= 1 {
+			res.Status = "succeeded"
+		} else if mostRecentJob.Status.Active >= 1 {
+			res.Status = "running"
+		} else if mostRecentJob.Status.Failed >= 1 {
+			res.Status = "failed"
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")

+ 14 - 0
server/router/router.go

@@ -1272,6 +1272,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/k8s/{namespace}/{name}/jobs/status",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetJobStatus, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}/pods",