From fd170f21c4daca22e730de0e8f9006adee31ae95 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Mon, 25 Sep 2023 14:40:48 -0400 Subject: [PATCH] [POR-1799] Porter app update-tag in porter.yaml v2 (#3640) Co-authored-by: d-g-town <66391417+d-g-town@users.noreply.github.com> --- api/client/porter_app.go | 25 +++++ .../handlers/porter_app/update_image.go | 97 +++++++++++++++++++ api/server/router/porter_app.go | 29 ++++++ cli/cmd/commands/app.go | 85 +++++++++------- cli/cmd/v2/update_image.go | 32 ++++++ go.mod | 2 +- go.sum | 4 +- 7 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 api/server/handlers/porter_app/update_image.go create mode 100644 cli/cmd/v2/update_image.go diff --git a/api/client/porter_app.go b/api/client/porter_app.go index f7f92cdb43..df612ddbf0 100644 --- a/api/client/porter_app.go +++ b/api/client/porter_app.go @@ -484,3 +484,28 @@ func (c *Client) PorterYamlV2Pods( return resp, err } + +// UpdateImage updates the image for a porter app (porter yaml v2 only) +func (c *Client) UpdateImage( + ctx context.Context, + projectID, clusterID uint, + appName, deploymentTargetId, tag string, +) (*porter_app.UpdateImageResponse, error) { + req := &porter_app.UpdateImageRequest{ + Tag: tag, + DeploymentTargetId: deploymentTargetId, + } + + resp := &porter_app.UpdateImageResponse{} + + err := c.postRequest( + fmt.Sprintf( + "/projects/%d/clusters/%d/apps/%s/update-image", + projectID, clusterID, appName, + ), + &req, + resp, + ) + + return resp, err +} diff --git a/api/server/handlers/porter_app/update_image.go b/api/server/handlers/porter_app/update_image.go new file mode 100644 index 0000000000..4f6ba68866 --- /dev/null +++ b/api/server/handlers/porter_app/update_image.go @@ -0,0 +1,97 @@ +package porter_app + +import ( + "net/http" + + "connectrpc.com/connect" + porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/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/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// UpdateImageHandler is the handler for the /apps/{porter_app_name}/update-image endpoint +type UpdateImageHandler struct { + handlers.PorterHandlerReadWriter + authz.KubernetesAgentGetter +} + +// NewUpdateImageHandler handles POST requests to the /apps/{porter_app_name}/update-image endpoint +func NewUpdateImageHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *UpdateImageHandler { + return &UpdateImageHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config), + } +} + +// UpdateImageRequest is the request object for the /apps/{porter_app_name}/update-image endpoint +type UpdateImageRequest struct { + DeploymentTargetId string `json:"deployment_target_id"` + Repository string `json:"repository"` + Tag string `json:"tag"` +} + +// UpdateImageResponse is the response object for the /apps/{porter_app_name}/update-image endpoint +type UpdateImageResponse struct { + Repository string `json:"repository"` + Tag string `json:"tag"` +} + +func (c *UpdateImageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-update-image") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + + if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) { + err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden)) + return + } + + appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName) + if reqErr != nil { + err := telemetry.Error(ctx, span, nil, "error parsing app name") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName}) + + request := &UpdateImageRequest{} + 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 + } + + updateImageReq := connect.NewRequest(&porterv1.UpdateAppImageRequest{ + ProjectId: int64(project.ID), + DeploymentTargetId: request.DeploymentTargetId, + RepositoryUrl: request.Repository, + Tag: request.Tag, + AppName: appName, + }) + ccpResp, err := c.Config().ClusterControlPlaneClient.UpdateAppImage(ctx, updateImageReq) + if err != nil { + err := telemetry.Error(ctx, span, err, "error calling ccp update porter app image") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + res := &UpdateImageResponse{ + Repository: ccpResp.Msg.RepositoryUrl, + Tag: ccpResp.Msg.Tag, + } + + c.WriteResult(w, r, res) +} diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go index 51ceb07fd6..cb7e9de534 100644 --- a/api/server/router/porter_app.go +++ b/api/server/router/porter_app.go @@ -688,6 +688,35 @@ func getPorterAppRoutes( Router: r, }) + // POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/update-image -> porter_app.NewUpdateImageHandler + updatePorterAppImageEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbUpdate, + Method: types.HTTPVerbPost, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/{%s}/update-image", relPathV2, types.URLParamPorterAppName), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.ClusterScope, + }, + }, + ) + + updatePorterAppImageHandler := porter_app.NewUpdateImageHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: updatePorterAppImageEndpoint, + Handler: updatePorterAppImageHandler, + Router: r, + }) + // GET /api/projects/{project_id}/clusters/{cluster_id}/default-deployment-target -> porter_app.NewDefaultDeploymentTargetHandler defaultDeploymentTargetEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/cli/cmd/commands/app.go b/cli/cmd/commands/app.go index 3c69479ee2..20d0cd42d1 100644 --- a/cli/cmd/commands/app.go +++ b/cli/cmd/commands/app.go @@ -14,6 +14,7 @@ import ( "github.com/porter-dev/porter/api/types" "github.com/porter-dev/porter/cli/cmd/config" "github.com/porter-dev/porter/cli/cmd/utils" + v2 "github.com/porter-dev/porter/cli/cmd/v2" "github.com/spf13/cobra" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" @@ -353,7 +354,7 @@ func appCleanup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie } for _, podName := range selectedPods { - color.New(color.FgBlue).Printf("Deleting ephemeral pod: %s\n", podName) + _, _ = color.New(color.FgBlue).Printf("Deleting ephemeral pod: %s\n", podName) err = config.Clientset.CoreV1().Pods(appNamespace).Delete( ctx, podName, metav1.DeleteOptions{}, @@ -602,7 +603,7 @@ func appExecuteRunEphemeral(ctx context.Context, config *AppPorterRunSharedConfi // delete the ephemeral pod no matter what defer appDeletePod(ctx, config, podName, namespace) //nolint:errcheck,gosec // do not want to change logic of CLI. New linter error - color.New(color.FgYellow).Printf("Waiting for pod %s to be ready...", podName) + _, _ = color.New(color.FgYellow).Printf("Waiting for pod %s to be ready...", podName) if err = appWaitForPod(ctx, config, newPod); err != nil { color.New(color.FgRed).Println("failed") return appHandlePodAttachError(ctx, err, config, namespace, podName, container) @@ -1145,44 +1146,58 @@ func appCreateEphemeralPodFromExisting( } func appUpdateTag(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, args []string) error { - namespace := fmt.Sprintf("porter-stack-%s", args[0]) - if appTag == "" { - appTag = "latest" - } - release, err := client.GetRelease(ctx, cliConfig.Project, cliConfig.Cluster, namespace, args[0]) + project, err := client.GetProject(ctx, cliConfig.Project) if err != nil { - return fmt.Errorf("Unable to find application %s", args[0]) - } - repository, ok := release.Config["global"].(map[string]interface{})["image"].(map[string]interface{})["repository"].(string) - if !ok || repository == "" { - return fmt.Errorf("Application %s does not have an associated image repository. Unable to update tag", args[0]) - } - imageInfo := types.ImageInfo{ - Repository: repository, - Tag: appTag, - } - createUpdatePorterAppRequest := &types.CreatePorterAppRequest{ - ClusterID: cliConfig.Cluster, - ProjectID: cliConfig.Project, - ImageInfo: imageInfo, - OverrideRelease: false, + return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run") } - color.New(color.FgGreen).Printf("Updating application %s to build using tag \"%s\"\n", args[0], appTag) + if project.ValidateApplyV2 { + tag, err := v2.UpdateImage(ctx, appTag, client, cliConfig.Project, cliConfig.Cluster, args[0]) + if err != nil { + return fmt.Errorf("error updating tag: %w", err) + } + _, _ = color.New(color.FgGreen).Printf("Successfully updated application %s to use tag \"%s\"\n", args[0], tag) + return nil + } else { + namespace := fmt.Sprintf("porter-stack-%s", args[0]) + if appTag == "" { + appTag = "latest" + } + release, err := client.GetRelease(ctx, cliConfig.Project, cliConfig.Cluster, namespace, args[0]) + if err != nil { + return fmt.Errorf("Unable to find application %s", args[0]) + } + repository, ok := release.Config["global"].(map[string]interface{})["image"].(map[string]interface{})["repository"].(string) + if !ok || repository == "" { + return fmt.Errorf("Application %s does not have an associated image repository. Unable to update tag", args[0]) + } + imageInfo := types.ImageInfo{ + Repository: repository, + Tag: appTag, + } + createUpdatePorterAppRequest := &types.CreatePorterAppRequest{ + ClusterID: cliConfig.Cluster, + ProjectID: cliConfig.Project, + ImageInfo: imageInfo, + OverrideRelease: false, + } + + _, _ = color.New(color.FgGreen).Printf("Updating application %s to build using tag \"%s\"\n", args[0], appTag) - _, err = client.CreatePorterApp( - ctx, - cliConfig.Project, - cliConfig.Cluster, - args[0], - createUpdatePorterAppRequest, - ) - if err != nil { - return fmt.Errorf("Unable to update application %s: %w", args[0], err) - } + _, err = client.CreatePorterApp( + ctx, + cliConfig.Project, + cliConfig.Cluster, + args[0], + createUpdatePorterAppRequest, + ) + if err != nil { + return fmt.Errorf("Unable to update application %s: %w", args[0], err) + } - color.New(color.FgGreen).Printf("Successfully updated application %s to use tag \"%s\"\n", args[0], appTag) - return nil + _, _ = color.New(color.FgGreen).Printf("Successfully updated application %s to use tag \"%s\"\n", args[0], appTag) + return nil + } } func getPodsFromV1PorterYaml(ctx context.Context, execArgs []string, client api.Client, cliConfig config.CLIConfig, porterAppName string, namespace string) ([]appPodSimple, []string, error) { diff --git a/cli/cmd/v2/update_image.go b/cli/cmd/v2/update_image.go new file mode 100644 index 0000000000..0b0bc09f93 --- /dev/null +++ b/cli/cmd/v2/update_image.go @@ -0,0 +1,32 @@ +package v2 + +import ( + "context" + "errors" + "fmt" + + api "github.com/porter-dev/porter/api/client" +) + +// UpdateImage updates the image of an application +func UpdateImage(ctx context.Context, tag string, client api.Client, projectId, clusterId uint, appName string) (string, error) { + targetResp, err := client.DefaultDeploymentTarget(ctx, projectId, clusterId) + if err != nil { + return "", fmt.Errorf("error calling default deployment target endpoint: %w", err) + } + + if targetResp.DeploymentTargetID == "" { + return "", errors.New("deployment target id is empty") + } + + if tag == "" { + tag = "latest" + } + + resp, err := client.UpdateImage(ctx, projectId, clusterId, appName, targetResp.DeploymentTargetID, tag) + if err != nil { + return "", fmt.Errorf("unable to update image: %w", err) + } + + return resp.Tag, nil +} diff --git a/go.mod b/go.mod index a5d0788014..e11ea66a14 100644 --- a/go.mod +++ b/go.mod @@ -82,7 +82,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.1.8 + github.com/porter-dev/api-contracts v0.1.9 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 diff --git a/go.sum b/go.sum index 5447876725..53417d7122 100644 --- a/go.sum +++ b/go.sum @@ -1516,8 +1516,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.1.8 h1:g8qq2TeN6W6T+FgQfv7RP/sDEFE2CxhK1sm6C4q78e8= -github.com/porter-dev/api-contracts v0.1.8/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8= +github.com/porter-dev/api-contracts v0.1.9 h1:EGNZjVjBKPIP+w7fcMhi3njWEt1V1kiK8cd2h87vFQk= +github.com/porter-dev/api-contracts v0.1.9/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=