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

Merge branch 'master' into 0.5.0-cluster-dashboard-namespaces-list

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

BIN
dashboard/src/assets/trash.png


+ 89 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -1,11 +1,37 @@
-import React, { useContext } from 'react'
+import React, { useContext, useState } from 'react'
 import styled from 'styled-components';
 import Heading from 'components/values-form/Heading';
 import Helper from "components/values-form/Helper";
+import InputRow from "components/values-form/InputRow";
 import { Context } from 'shared/Context';
+import api from "shared/api";
 
-export const ClusterSettings = () => {
+const ClusterSettings: React.FC = () => {
   const context = useContext(Context);
+  const [accessKeyId, setAccessKeyId] = useState<string>("");
+  const [secretKey, setSecretKey] = useState<string>("");
+  const [startRotateCreds, setStartRotateCreds] = useState<boolean>(false);
+  const [successfulRotate, setSuccessfulRotate] = useState<boolean>(false);
+
+  let rotateCredentials = () => {
+    api.overwriteAWSIntegration(
+      "<token>",
+      {
+        aws_access_key_id: accessKeyId,
+        aws_secret_access_key: secretKey
+      },
+      {
+        projectID: context.currentProject.id,
+        awsIntegrationID: context.currentCluster.aws_integration_id,
+        cluster_id: context.currentCluster.id,
+      }
+    ).then(({ data }) => {
+      setSuccessfulRotate(true)
+    })
+    .catch(() => {
+      setSuccessfulRotate(false)
+    })
+  }
 
   let helperText = <Helper>
     Delete this cluster and underlying infrastructure. To
@@ -29,9 +55,69 @@ export const ClusterSettings = () => {
     </Helper>
   }
 
+  let keyRotationSection = null
+
+  if (context.currentCluster?.aws_integration_id && context.currentCluster?.aws_integration_id != 0) {
+    if (successfulRotate) {
+      keyRotationSection = <div>
+        <Heading>Credential Rotation</Heading>
+        <Helper>
+          Successfully rotated credentials!
+        </Helper>
+        </div>
+    } else if (startRotateCreds) {
+      keyRotationSection = <div>
+        <Heading>Credential Rotation</Heading>
+        <Helper>
+          Input the new credentials for the EKS cluster. 
+        </Helper>
+        <InputRow
+          type="text"
+          value={accessKeyId}
+          setValue={(x: string) => setAccessKeyId(x)}
+          label="👤 AWS Access ID"
+          placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+          width="100%"
+          isRequired={true}
+        />
+        <InputRow
+          type="password"
+          value={secretKey}
+          setValue={(x: string) => setSecretKey(x)}
+          label="🔒 AWS Secret Key"
+          placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+          width="100%"
+          isRequired={true}
+        />
+        <Button
+          color="#616FEEcc"
+          onClick={rotateCredentials}
+        >
+          Submit
+        </Button>
+      </div>
+    } else {
+      keyRotationSection = <div>
+        <Heading>Credential Rotation</Heading>
+        <Helper>
+          Rotate the credentials that Porter uses to connect to the cluster.
+        </Helper>
+        <Button
+          color="#616FEEcc"
+          onClick={() => setStartRotateCreds(true)}
+        >
+          Rotate Credentials
+        </Button>
+        
+      </div>
+    }
+    
+  }
+
   return (
     <div>
       <StyledSettingsSection showSource={false}>
+          {keyRotationSection}
           <Heading>Delete Cluster</Heading>
           {helperText}
           <Button
@@ -45,6 +131,7 @@ export const ClusterSettings = () => {
   )
 }
 
+export default ClusterSettings
 
 const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   margin-top: 35px;

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -5,8 +5,10 @@ import { Context } from "shared/Context";
 import TabSelector from "components/TabSelector";
 
 import NodeList from "./NodeList";
-import { ClusterSettings } from "./ClusterSettings";
+
 import { NamespaceList } from "./NamespaceList";
+import ClusterSettings from "./ClusterSettings";
+
 
 type TabEnum = "nodes" | "settings" | "namespaces";
 

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

@@ -141,6 +141,14 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     this.sortJobsAndSave(jobs);
   };
 
+  removeJob = (deletedJob: any) => {
+    let jobs = this.state.jobs.filter(job => {
+      return deletedJob.metadata?.name !== job.metadata?.name
+    });
+
+    this.sortJobsAndSave(jobs);
+  }
+
   setupJobWebsocket = (chart: ChartType) => {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
@@ -173,6 +181,20 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         ) {
           this.mergeNewJob(event.Object);
         }
+      } else if (event.event_type == "DELETE") {
+        // filter job belonging to chart
+        let chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
+        let releaseLabel =
+          event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+
+        if (
+          chartLabel &&
+          releaseLabel &&
+          chartLabel == chartVersion &&
+          releaseLabel == chart.name
+        ) {
+          this.removeJob(event.Object)
+        }
       }
     };
 
@@ -409,7 +431,12 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         }
         return (
           <TabWrapper>
-            <JobList jobs={this.state.jobs} />
+            <JobList 
+              jobs={this.state.jobs} 
+              setJobs={(jobs: any) => {
+                this.setState({ jobs })
+              }}
+            />
             <SaveButton
               text="Rerun Job"
               onClick={() => this.handleSaveValues(submitValues)}

+ 65 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -1,17 +1,28 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
+import api from "shared/api";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import JobResource from "./JobResource";
+import ConfirmOverlay from "components/ConfirmOverlay";
 
 type PropsType = {
   jobs: any[];
+  setJobs: (job: any) => void;
 };
 
-type StateType = {};
+type StateType = {
+  deletionCandidate: any;
+  deletionJob: any;
+};
 
 export default class JobList extends Component<PropsType, StateType> {
+  state = {
+    deletionCandidate: null as any,
+    deletionJob: null as any,
+  }
+
   renderJobList = () => {
     if (this.props.jobs.length === 0) {
       return (
@@ -24,15 +35,66 @@ export default class JobList extends Component<PropsType, StateType> {
       return (
         <>
           {this.props.jobs.map((job: any, i: number) => {
-            return <JobResource key={job?.metadata?.name} job={job} />;
+            return (
+              <JobResource
+                key={job?.metadata?.name}
+                job={job} 
+                handleDelete={() => this.setState({ deletionCandidate: job })}
+                deleting={this.state.deletionJob?.metadata?.name == job.metadata?.name}
+              />
+            );
           })}
         </>
       );
     }
   };
 
+  deleteJob = () => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let job = this.state.deletionCandidate
+
+    api
+      .deleteJob(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: job.metadata?.name,
+          namespace: job.metadata?.namespace,
+        }
+      )
+      .then((res) => {
+        this.setState({
+          deletionJob: this.state.deletionCandidate,
+          deletionCandidate: null,
+        })
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        setCurrentError(err);
+      });
+  }
+
   render() {
-    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
+    return (
+      <>
+        <ConfirmOverlay
+          show={this.state.deletionCandidate}
+          message={`Are you sure you want to delete this job run?`}
+          onYes={this.deleteJob}
+          onNo={() => this.setState({ deletionCandidate: null })}
+        />
+        <JobListWrapper>
+          {this.renderJobList()}
+        </JobListWrapper>
+      </>
+    );
   }
 }
 

+ 47 - 25
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -7,10 +7,13 @@ import api from "shared/api";
 import Logs from "../status/Logs";
 import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
+import trash from "assets/trash.png";
 import KeyValueArray from "components/values-form/KeyValueArray";
 
 type PropsType = {
   job: any;
+  handleDelete: () => void;
+  deleting: boolean;
 };
 
 type StateType = {
@@ -55,7 +58,14 @@ export default class JobResource extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {})
-      .catch((err) => setCurrentError(JSON.stringify(err)));
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        setCurrentError(err);
+      });
   };
 
   getPods = (callback: () => void) => {
@@ -217,6 +227,10 @@ export default class JobResource extends Component<PropsType, StateType> {
   };
 
   renderStatus = () => {
+    if (this.props.deleting) {
+      return <Status color="#cc3d42">Deleting</Status>;
+    }
+
     if (this.props.job.status?.succeeded >= 1) {
       return <Status color="#38a88a">Succeeded</Status>;
     }
@@ -249,30 +263,38 @@ export default class JobResource extends Component<PropsType, StateType> {
     );
 
     return (
-      <StyledJob>
-        <MainRow onClick={this.expandJob}>
-          <Flex>
-            <Icon src={icon && icon} />
-            <Description>
-              <Label>
-                Started at {this.readableDate(this.props.job.status?.startTime)}
-              </Label>
-              <Subtitle>{this.getSubtitle()}</Subtitle>
-            </Description>
-          </Flex>
-          <EndWrapper>
-            <CommandString>{commandString}</CommandString>
-            {this.renderStatus()}
-            <MaterialIconTray disabled={false}>
-              {this.renderStopButton()}
-              <i className="material-icons" onClick={this.expandJob}>
-                {this.state.expanded ? "expand_less" : "expand_more"}
-              </i>
-            </MaterialIconTray>
-          </EndWrapper>
-        </MainRow>
-        {this.renderLogsSection()}
-      </StyledJob>
+      <>
+        <StyledJob>
+          <MainRow onClick={this.expandJob}>
+            <Flex>
+              <Icon src={icon && icon} />
+              <Description>
+                <Label>
+                  Started at {this.readableDate(this.props.job.status?.startTime)}
+                </Label>
+                <Subtitle>{this.getSubtitle()}</Subtitle>
+              </Description>
+            </Flex>
+            <EndWrapper>
+              <CommandString>{commandString}</CommandString>
+              {this.renderStatus()}
+              <MaterialIconTray disabled={false}>
+                {this.renderStopButton()}
+                <i className="material-icons" onClick={(e) => {
+                  e.stopPropagation();
+                  this.props.handleDelete();
+                }}>
+                  delete
+                </i>
+                <i className="material-icons" onClick={this.expandJob}>
+                  {this.state.expanded ? "expand_less" : "expand_more"}
+                </i>
+              </MaterialIconTray>
+            </EndWrapper>
+          </MainRow>
+          {this.renderLogsSection()}
+        </StyledJob>
+      </>
     );
   }
 }

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

@@ -45,6 +45,20 @@ const createAWSIntegration = baseApi<
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
+const overwriteAWSIntegration = baseApi<
+  {
+    aws_access_key_id: string,
+    aws_secret_access_key: string,
+  },
+  { 
+    projectID: number,
+    awsIntegrationID: number,
+    cluster_id: number,
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.projectID}/integrations/aws/${pathParams.awsIntegrationID}/overwrite?cluster_id=${pathParams.cluster_id}`;
+});
+
 const createDOCR = baseApi<
   {
     do_integration_id: number;
@@ -839,6 +853,14 @@ const deleteNamespace = baseApi<
   return `/api/projects/${id}/k8s/namespaces/delete`;
 });
 
+const deleteJob = baseApi<
+  { cluster_id: number },
+  { name: string; namespace: string; id: number }
+>("DELETE", (pathParams) => {
+  let { id, name, namespace } = pathParams;
+  return `/api/projects/${id}/k8s/jobs/${namespace}/${name}`;
+});
+
 const stopJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
@@ -853,6 +875,7 @@ export default {
   connectECRRegistry,
   connectGCRRegistry,
   createAWSIntegration,
+  overwriteAWSIntegration,
   createDOCR,
   createDOKS,
   createEmailVerification,
@@ -933,5 +956,6 @@ export default {
   updateUser,
   updateConfigMap,
   upgradeChartValues,
+  deleteJob,
   stopJob,
 };

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -5,6 +5,7 @@ export interface ClusterType {
   service_account_id: number;
   infra_id?: number;
   service?: string;
+  aws_integration_id?: number;
 }
 
 export interface DetailedClusterType extends ClusterType {

+ 9 - 0
internal/forms/integration.go

@@ -66,3 +66,12 @@ func (caf *CreateAWSIntegrationForm) ToAWSIntegration() (*ints.AWSIntegration, e
 		AWSSecretAccessKey: []byte(caf.AWSSecretAccessKey),
 	}, nil
 }
+
+// OverwriteAWSIntegrationForm represents the accepted values for overwriting an
+// AWS Integration
+type OverwriteAWSIntegrationForm struct {
+	UserID             uint   `json:"user_id" form:"required"`
+	ProjectID          uint   `json:"project_id" form:"required"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}

+ 9 - 0
internal/kubernetes/agent.go

@@ -284,6 +284,15 @@ func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Jo
 	return resp.Items, nil
 }
 
+// DeleteJob deletes the job in the given name and namespace.
+func (a *Agent) DeleteJob(name, namespace string) error {
+	return a.Clientset.BatchV1().Jobs(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
 // GetJobPods lists all pods belonging to a job in a namespace
 func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
 	resp, err := a.Clientset.CoreV1().Pods(namespace).List(

+ 10 - 6
internal/models/cluster.go

@@ -86,6 +86,9 @@ type ClusterExternal struct {
 
 	// The infra id, if cluster was provisioned with Porter
 	InfraID uint `json:"infra_id"`
+
+	// (optional) The aws integration id, if available
+	AWSIntegrationID uint `json:"aws_integration_id"`
 }
 
 // Externalize generates an external Cluster to be shared over REST
@@ -101,12 +104,13 @@ func (c *Cluster) Externalize() *ClusterExternal {
 	}
 
 	return &ClusterExternal{
-		ID:        c.ID,
-		ProjectID: c.ProjectID,
-		Name:      c.Name,
-		Server:    c.Server,
-		Service:   serv,
-		InfraID:   c.InfraID,
+		ID:               c.ID,
+		ProjectID:        c.ProjectID,
+		Name:             c.Name,
+		Server:           c.Server,
+		Service:          serv,
+		InfraID:          c.InfraID,
+		AWSIntegrationID: c.AWSIntegrationID,
 	}
 }
 

+ 17 - 0
internal/repository/gorm/auth.go

@@ -936,6 +936,23 @@ func (repo *AWSIntegrationRepository) CreateAWSIntegration(
 	return am, nil
 }
 
+// UpdateCluster modifies an existing Cluster in the database
+func (repo *AWSIntegrationRepository) OverwriteAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	err := repo.EncryptAWSIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
 // ReadAWSIntegration finds a aws auth mechanism by id
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 	id uint,

+ 51 - 0
internal/repository/gorm/auth_test.go

@@ -499,6 +499,57 @@ func TestCreateAWSIntegration(t *testing.T) {
 	}
 }
 
+func TestOverwriteAWSIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_overwrite_aws.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initAWSIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	aws, err := tester.repo.AWSIntegration.ReadAWSIntegration(1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	aws.AWSAccessKeyID = []byte("accesskey2")
+	aws.AWSSecretAccessKey = []byte("secret2")
+
+	aws, err = tester.repo.AWSIntegration.OverwriteAWSIntegration(aws)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gotAWS, err := tester.repo.AWSIntegration.ReadAWSIntegration(1)
+
+	expAWS := &ints.AWSIntegration{
+		ProjectID:          tester.initProjects[0].ID,
+		UserID:             tester.initUsers[0].ID,
+		AWSClusterID:       []byte("example-cluster-0"),
+		AWSAccessKeyID:     []byte("accesskey2"),
+		AWSSecretAccessKey: []byte("secret2"),
+		AWSSessionToken:    []byte("optional"),
+	}
+
+	// make sure id is 1
+	if gotAWS.Model.ID != 1 {
+		t.Errorf("incorrect aws integration ID: expected %d, got %d\n", 1, gotAWS.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	gotAWS.Model = orm.Model{}
+
+	if diff := deep.Equal(expAWS, gotAWS); diff != nil {
+		t.Errorf("incorrect aws integration")
+		t.Error(diff)
+	}
+}
+
 func TestListAWSIntegrationsByProjectID(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_list_awss.db",

+ 1 - 0
internal/repository/integrations.go

@@ -41,6 +41,7 @@ type OAuthIntegrationRepository interface {
 // mechanism
 type AWSIntegrationRepository interface {
 	CreateAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
+	OverwriteAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
 	ReadAWSIntegration(id uint) (*ints.AWSIntegration, error)
 	ListAWSIntegrationsByProjectID(projectID uint) ([]*ints.AWSIntegration, error)
 }

+ 17 - 0
internal/repository/memory/auth.go

@@ -311,6 +311,23 @@ func (repo *AWSIntegrationRepository) CreateAWSIntegration(
 	return am, nil
 }
 
+func (repo *AWSIntegrationRepository) OverwriteAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(am.ID-1) >= len(repo.awsIntegrations) || repo.awsIntegrations[am.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(am.ID - 1)
+	repo.awsIntegrations[index] = am
+
+	return am, nil
+}
+
 // ReadAWSIntegration finds a aws auth mechanism by id
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 	id uint,

+ 101 - 0
server/api/integration_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"net/url"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -186,6 +187,106 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleOverwriteAWSIntegration overwrites the ID of an AWS integration in the DB
+func (app *App) HandleOverwriteAWSIntegration(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	awsIntegrationID, err := strconv.ParseUint(chi.URLParam(r, "aws_integration_id"), 0, 64)
+
+	if err != nil || awsIntegrationID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.OverwriteAWSIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// read the aws integration by ID and overwrite the access id/secret
+	awsIntegration, err := app.Repo.AWSIntegration.ReadAWSIntegration(uint(awsIntegrationID))
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	awsIntegration.AWSAccessKeyID = []byte(form.AWSAccessKeyID)
+	awsIntegration.AWSSecretAccessKey = []byte(form.AWSSecretAccessKey)
+
+	// handle write to the database
+	awsIntegration, err = app.Repo.AWSIntegration.OverwriteAWSIntegration(awsIntegration)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// clear the cluster token cache if cluster_id exists
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	if len(vals["cluster_id"]) > 0 {
+		clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+
+		cluster, err := app.Repo.Cluster.ReadCluster(uint(clusterID))
+
+		// clear the token
+		cluster.TokenCache.Token = []byte("")
+
+		cluster, err = app.Repo.Cluster.UpdateClusterTokenCache(&cluster.TokenCache)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+	}
+
+	app.Logger.Info().Msgf("AWS integration overwritten: %d", awsIntegration.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	awsExt := awsIntegration.Externalize()
+
+	if err := json.NewEncoder(w).Encode(awsExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
 func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
 	userID, err := app.getUserIDFromRequest(r)

+ 52 - 1
server/api/k8s_handler.go

@@ -855,6 +855,54 @@ func (app *App) HandleListJobsByChart(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleDeleteJob(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// 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.DeleteJob(name, namespace)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleStopJob stops a running job
 func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
@@ -896,7 +944,10 @@ func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 	err = agent.StopJobWithJobSidecar(namespace, name)
 
 	if err != nil {
-		app.handleErrorInternal(err, w)
+		app.sendExternalError(err, 500, HTTPError{
+			Code:   500,
+			Errors: []string{err.Error()},
+		}, w)
 		return
 	}
 

+ 33 - 0
server/router/router.go

@@ -693,6 +693,25 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/integrations/aws/{aws_integration_id}/overwrite",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						auth.DoesUserHaveAWSIntegrationAccess(
+							requestlog.NewHandler(a.HandleOverwriteAWSIntegration, l),
+							mw.URLParam,
+							mw.URLParam,
+							false,
+						),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/integrations/basic",
@@ -1351,6 +1370,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/jobs/{namespace}/{name}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteJob, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}/stop",