Bläddra i källkod

cloudsql support (#4395)

d-g-town 2 år sedan
förälder
incheckning
726e429bc0

+ 174 - 0
api/server/handlers/porter_app/cloudsql.go

@@ -0,0 +1,174 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// GetCloudSqlSecretHandler is a handler to get the cloudsql secret
+type GetCloudSqlSecretHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewGetCloudSqlSecretHandler returns a GetCloudSqlSecretHandler
+func NewGetCloudSqlSecretHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetCloudSqlSecretHandler {
+	return &GetCloudSqlSecretHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// GetCloudSqlSecretResponse is the response payload for the GetCloudSqlSecretHandler
+type GetCloudSqlSecretResponse struct {
+	SecretName string `json:"secret_name"`
+}
+
+// ServeHTTP retrieves the cloudsql secret
+func (c *GetCloudSqlSecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-cloudsql-secret")
+	defer span.End()
+
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	cluster, err := c.Repo().Cluster().ReadCluster(deploymentTarget.ProjectID, deploymentTarget.ClusterID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	secret, err := agent.GetSecret(fmt.Sprintf("cloudsql-secret-%s", appName), deploymentTarget.Namespace)
+	if err != nil && !k8serrors.IsNotFound(err) {
+		err = telemetry.Error(ctx, span, err, "error getting secret")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var secretName string
+	if secret != nil {
+		secretName = secret.Name
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "secret-name", Value: secretName})
+
+	c.WriteResult(w, r, GetCloudSqlSecretResponse{SecretName: secretName})
+}
+
+// CreateCloudSqlSecretHandler is a handler to create the cloudsql secret
+type CreateCloudSqlSecretHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewCreateCloudSqlSecretHandler returns a CreateCloudSqlSecretHandler
+func NewCreateCloudSqlSecretHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateCloudSqlSecretHandler {
+	return &CreateCloudSqlSecretHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// CreateCloudSqlSecretRequest is the request payload for the CreateCloudSqlSecretHandler
+type CreateCloudSqlSecretRequest struct {
+	B64ServiceAccountJson string `json:"b64_service_account_json"`
+}
+
+// ServeHTTP creates the cloudsql secret
+func (c *CreateCloudSqlSecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "serve-create-cloudsql-secret")
+	defer span.End()
+
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	request := &CreateCloudSqlSecretRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	cluster, err := c.Repo().Cluster().ReadCluster(deploymentTarget.ProjectID, deploymentTarget.ClusterID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(request.B64ServiceAccountJson)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error decoding base64 service account json")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	secret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: fmt.Sprintf("cloudsql-secret-%s", appName),
+		},
+		Data: map[string][]byte{
+			"service_account.json": decoded,
+		},
+	}
+
+	_, err = agent.CreateSecret(secret, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating secret")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 60 - 0
api/server/router/deployment_target.go

@@ -1,8 +1,11 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -85,5 +88,62 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/cloudsql -> porter_app.GetCloudSqlSecretHandler
+	getCloudSqlSecretEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/apps/{porter_app_name}/cloudsql", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	getCloudSqlSecretHandler := porter_app.NewGetCloudSqlSecretHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getCloudSqlSecretEndpoint,
+		Handler:  getCloudSqlSecretHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/cloudsql -> porter_app.CreateCloudSqlSecretHandler
+	createCloudSqlSecretEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/apps/{porter_app_name}/cloudsql", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	createCloudSqlSecretHandler := porter_app.NewCreateCloudSqlSecretHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createCloudSqlSecretEndpoint,
+		Handler:  createCloudSqlSecretHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 13 - 13
dashboard/package-lock.json

@@ -95,7 +95,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.113",
+        "@porter-dev/api-contracts": "^0.2.118",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2072,9 +2072,9 @@
       }
     },
     "node_modules/@bufbuild/protobuf": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.3.tgz",
-      "integrity": "sha512-AoHSiIpTFF97SQgmQni4c+Tyr0CDhkaRaR2qGEJTEbauqQwLRpLrd9yVv//wVHOSxr/b4FJcL54VchhY6710xA==",
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz",
+      "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==",
       "dev": true
     },
     "node_modules/@discoveryjs/json-ext": {
@@ -2754,9 +2754,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.113",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.113.tgz",
-      "integrity": "sha512-pk6JMuY/qSVMIcC7lw28PGPHcHT7qCn1xosug8TvpJ3fMNav1seotgBpqPh4CUQ8b1cF5PtAZWvEN+dx4bt/qg==",
+      "version": "0.2.118",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.118.tgz",
+      "integrity": "sha512-A5cPRfTNKfC7qQ6gHFLyLRWU1bTDj4mHIB2XL4l3CqUl3KsX6p7EgwjEI3YX5sVwoUcGnlatiZ+BqgrLhlf4cg==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
@@ -19584,9 +19584,9 @@
       }
     },
     "@bufbuild/protobuf": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.3.tgz",
-      "integrity": "sha512-AoHSiIpTFF97SQgmQni4c+Tyr0CDhkaRaR2qGEJTEbauqQwLRpLrd9yVv//wVHOSxr/b4FJcL54VchhY6710xA==",
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz",
+      "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==",
       "dev": true
     },
     "@discoveryjs/json-ext": {
@@ -20056,9 +20056,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.113",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.113.tgz",
-      "integrity": "sha512-pk6JMuY/qSVMIcC7lw28PGPHcHT7qCn1xosug8TvpJ3fMNav1seotgBpqPh4CUQ8b1cF5PtAZWvEN+dx4bt/qg==",
+      "version": "0.2.118",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.118.tgz",
+      "integrity": "sha512-A5cPRfTNKfC7qQ6gHFLyLRWU1bTDj4mHIB2XL4l3CqUl3KsX6p7EgwjEI3YX5sVwoUcGnlatiZ+BqgrLhlf4cg==",
       "dev": true,
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -102,7 +102,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.113",
+    "@porter-dev/api-contracts": "^0.2.118",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 47 - 0
dashboard/src/lib/hooks/useCloudSqlSecret.ts

@@ -0,0 +1,47 @@
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import api from "shared/api";
+
+export function useCloudSqlSecret({
+  appName,
+  deploymentTargetId,
+  projectId,
+}: {
+  appName: string;
+  deploymentTargetId: string;
+  projectId: number;
+}): boolean {
+  const { data } = useQuery(
+    [
+      "getCloudSqlSecret",
+      projectId,
+      appName,
+      deploymentTargetId,
+    ],
+    async () => {
+      const res = await api.getCloudSqlSecret(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          deployment_target_id: deploymentTargetId,
+          app_name: appName,
+        }
+      );
+
+      const secret = await z
+        .object({
+          secret_name: z.string(),
+        })
+        .parseAsync(res.data);
+      return secret;
+    },
+    {
+      refetchInterval: 5000,
+      refetchOnWindowFocus: false,
+    }
+  );
+
+  return data !== undefined && data.secret_name !== "";
+}

+ 41 - 0
dashboard/src/lib/porter-apps/index.ts

@@ -1,6 +1,7 @@
 import {
   AutoRollback,
   Build,
+  CloudSql,
   EFS,
   HelmOverrides,
   PorterApp,
@@ -81,6 +82,19 @@ export const clientAppValidator = z.object({
     enabled: z.boolean(),
     readOnly: z.boolean().optional(),
   }),
+  cloudSql: z
+    .object({
+      enabled: z.boolean(),
+      connectionName: z.string(),
+      dbPort: z.coerce.number(),
+      serviceAccountJsonSecret: z.string(),
+    })
+    .default({
+      enabled: false,
+      connectionName: "",
+      dbPort: 5432,
+      serviceAccountJsonSecret: "",
+    }),
   envGroups: z
     .object({ name: z.string(), version: z.bigint() })
     .array()
@@ -325,6 +339,13 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           efsStorage: new EFS({
             enabled: app.efsStorage.enabled,
           }),
+          cloudSql: new CloudSql({
+            enabled: app.cloudSql.enabled,
+            connectionName: app.cloudSql?.connectionName ?? "",
+            serviceAccountJsonSecret:
+              app.cloudSql?.serviceAccountJsonSecret ?? "",
+            dbPort: app.cloudSql?.dbPort ?? 5432,
+          }),
           requiredApps: app.requiredApps.map((app) => ({
             name: app.name,
           })),
@@ -357,6 +378,13 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           efsStorage: new EFS({
             enabled: app.efsStorage.enabled,
           }),
+          cloudSql: new CloudSql({
+            enabled: app.cloudSql.enabled,
+            connectionName: app.cloudSql?.connectionName ?? "",
+            serviceAccountJsonSecret:
+              app.cloudSql?.serviceAccountJsonSecret ?? "",
+            dbPort: app.cloudSql?.dbPort ?? 5432,
+          }),
           requiredApps: app.requiredApps.map((app) => ({
             name: app.name,
           })),
@@ -510,6 +538,13 @@ export function clientAppFromProto({
       efsStorage: new EFS({
         enabled: proto.efsStorage?.enabled ?? false,
       }),
+      cloudSql: {
+        enabled: proto.cloudSql?.enabled ?? false,
+        connectionName: proto.cloudSql?.connectionName ?? "",
+        serviceAccountJsonSecret:
+          proto.cloudSql?.serviceAccountJsonSecret ?? "",
+        dbPort: proto.cloudSql?.dbPort ?? 5432,
+      },
       requiredApps: proto.requiredApps.map((app) => ({
         name: app.name,
       })),
@@ -556,6 +591,12 @@ export function clientAppFromProto({
     },
     helmOverrides,
     efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
+    cloudSql: {
+      enabled: proto.cloudSql?.enabled ?? false,
+      connectionName: proto.cloudSql?.connectionName ?? "",
+      serviceAccountJsonSecret: proto.cloudSql?.serviceAccountJsonSecret ?? "",
+      dbPort: proto.cloudSql?.dbPort ?? 5432,
+    },
     requiredApps: proto.requiredApps.map((app) => ({
       name: app.name,
     })),

+ 136 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
 import { Controller, useFormContext } from "react-hook-form";
 import { useHistory } from "react-router";
 import styled from "styled-components";
+import { z } from "zod";
 
 import Button from "components/porter/Button";
 import Checkbox from "components/porter/Checkbox";
@@ -16,6 +17,12 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import document from "assets/document.svg";
 
+import UploadArea from "components/form-components/UploadArea";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Input from "components/porter/Input";
+import { useCloudSqlSecret } from "lib/hooks/useCloudSqlSecret";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
 import { useLatestRevision } from "../LatestRevisionContext";
 import ExportAppModal from "./ExportAppModal";
@@ -29,7 +36,7 @@ const Settings: React.FC = () => {
   const { porterApp, clusterId, projectId } = useLatestRevision();
   const { updateAppStep } = useAppAnalytics();
   const [isDeleting, setIsDeleting] = useState(false);
-  const { control } = useFormContext<PorterAppFormData>();
+  const { control, register, watch } = useFormContext<PorterAppFormData>();
   const [githubWorkflowFilename, setGithubWorkflowFilename] = useState(
     `porter_stack_${porterApp.name}.yml`
   );
@@ -175,6 +182,7 @@ const Settings: React.FC = () => {
           </Checkbox>
         )}
       />
+      {currentCluster?.cloud_provider === "GCP" && <CloudSql />}
       <Spacer y={1} />
       {currentCluster?.cloud_provider === "AWS" &&
         currentProject?.efs_enabled && (
@@ -272,6 +280,133 @@ const Settings: React.FC = () => {
 
 export default Settings;
 
+const CloudSql: React.FC = () => {
+  const { register, control, watch, setValue } =
+    useFormContext<PorterAppFormData>();
+  const { currentDeploymentTarget } = useDeploymentTarget();
+  const [created, setCreated] = useState(false);
+
+  if (!currentDeploymentTarget) {
+    return null;
+  }
+
+  const cloudSqlEnabled = watch(`app.cloudSql.enabled`);
+  const appName = watch(`app.name.value`);
+
+  const secretExists = useCloudSqlSecret({
+    projectId: currentDeploymentTarget.project_id,
+    deploymentTargetId: currentDeploymentTarget.id,
+    appName,
+  });
+
+  const handleLoadJSON = async (data: string): Promise<void> => {
+    try {
+      await api.createCloudSqlSecret(
+        "<token>",
+        {
+          b64_service_account_json: btoa(data),
+        },
+        {
+          project_id: currentDeploymentTarget.project_id,
+          deployment_target_id: currentDeploymentTarget.id,
+          app_name: appName,
+        }
+      );
+      setCreated(true);
+    } catch (err) {}
+  };
+
+  const enabled = watch(`app.cloudSql.enabled`);
+
+  useEffect(() => {
+    if (enabled) {
+      setValue(
+        `app.cloudSql.serviceAccountJsonSecret`,
+        `cloudsql-secret-${appName}`
+      );
+    }
+  }, [enabled]);
+
+  return (
+    <>
+      <Spacer y={1} />
+      <Text>CloudSQL proxy</Text>
+      <Spacer y={0.25} />
+      <Text color="helper">
+        When enabled, Porter will automatically deploy a CloudSQL proxy with
+        your application, allowing all your services to securely access your
+        CloudSQL instance.
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`app.cloudSql.enabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text color="helper">Enable CloudSQL Proxy</Text>
+          </Checkbox>
+        )}
+      />
+      {cloudSqlEnabled && (
+        <>
+          <Spacer y={0.75} />
+          <Text color="helper">Connection Name</Text>
+          <Spacer y={0.25} />
+          <ControlledInput
+            type="text"
+            placeholder="ex: project:us-east1:instance"
+            {...register(`app.cloudSql.connectionName`)}
+          />
+          <Spacer y={0.5} />
+          <Text color="helper">Port</Text>
+          <Spacer y={0.25} />
+          <Controller
+            name={`app.cloudSql.dbPort`}
+            control={control}
+            render={({ field: { value, onChange } }) => (
+              <Input
+                placeholder={"ex: 5432"}
+                value={value.toString()}
+                setValue={(x: string) => {
+                  onChange(z.coerce.number().parse(x));
+                }}
+              />
+            )}
+          />
+          <Spacer y={0.5} />
+          <Container row>
+            <Text color={"helper"}>Service Account JSON</Text>
+            <Spacer inline x={0.5} />
+            {secretExists && created && (
+              <i className="material-icons">done</i>
+            )}{" "}
+          </Container>
+          <UploadArea
+            setValue={(x: string) => {
+              handleLoadJSON(x).catch(() => {});
+            }}
+            label=""
+            placeholder={
+              (secretExists
+                ? "To update your credentials, "
+                : "To enable the CloudSql Proxy, ") +
+              "drag a GCP Service Account JSON here, or click to browse."
+            }
+            width="100%"
+            height="100%"
+            isRequired={false}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
 const StyledSettingsTab = styled.div`
   width: 100%;
 `;

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

@@ -3475,6 +3475,26 @@ const getCloudProviderPermissionsStatus = baseApi<
     `/api/projects/${project_id}/integrations/cloud-permissions`
 );
 
+const getCloudSqlSecret = baseApi<
+  {},
+  { project_id: number; deployment_target_id: string; app_name: string }
+>(
+  "GET",
+  ({ project_id, deployment_target_id, app_name }) =>
+    `/api/projects/${project_id}/targets/${deployment_target_id}/apps/${app_name}/cloudsql`
+);
+
+const createCloudSqlSecret = baseApi<
+  {
+    b64_service_account_json: string;
+  },
+  { project_id: number; deployment_target_id: string; app_name: string }
+>(
+  "POST",
+  ({ project_id, deployment_target_id, app_name }) =>
+    `/api/projects/${project_id}/targets/${deployment_target_id}/apps/${app_name}/cloudsql`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -3767,4 +3787,7 @@ export default {
   getGithubStatus,
   getDatabaseStatus,
   getCloudProviderPermissionsStatus,
+
+  getCloudSqlSecret,
+  createCloudSqlSecret,
 };

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.116
+	github.com/porter-dev/api-contracts v0.2.118
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 6
go.sum

@@ -1523,12 +1523,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.113 h1:sv1huO9MpykJaWhV2D5zTD2LouMbRSBV5ATt/5Ukrbo=
-github.com/porter-dev/api-contracts v0.2.113/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.114 h1:qfEq70BJ8xXTkiZU7ygzOSGnMCqJHOa5Lbkfu4OzQBI=
-github.com/porter-dev/api-contracts v0.2.114/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.116 h1:Oe7pO69yyv1lEnafGWnKoHqNYJAe8KpQCE9cnTC1m1w=
-github.com/porter-dev/api-contracts v0.2.116/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.118 h1:xiDEn+KMYMmKSpSUm27wgBYnOY2cA+bDdIpEJHf54Bo=
+github.com/porter-dev/api-contracts v0.2.118/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 25 - 0
internal/kubernetes/agent.go

@@ -587,6 +587,31 @@ func (a *Agent) GetSecret(name string, namespace string) (*v1.Secret, error) {
 	)
 }
 
+// CreateSecret creates the secret given its name and namespace
+func (a *Agent) CreateSecret(secret *v1.Secret, namespace string) (*v1.Secret, error) {
+	_, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+		context.TODO(),
+		secret.Name,
+		metav1.GetOptions{},
+	)
+	if err != nil {
+		if !errors.IsNotFound(err) {
+			return nil, err
+		}
+		return a.Clientset.CoreV1().Secrets(namespace).Create(
+			context.TODO(),
+			secret,
+			metav1.CreateOptions{},
+		)
+	}
+
+	return a.Clientset.CoreV1().Secrets(namespace).Update(
+		context.TODO(),
+		secret,
+		metav1.UpdateOptions{},
+	)
+}
+
 // ListConfigMaps simply lists namespaces
 func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).List(