Преглед на файлове

standalone app build command (#4416)

ianedwards преди 2 години
родител
ревизия
b980cd4ed6
променени са 9 файла, в които са добавени 313 реда и са изтрити 18 реда
  1. 26 5
      api/client/porter_app.go
  2. 47 9
      api/server/handlers/porter_app/get_build.go
  3. 1 1
      api/server/router/porter_app.go
  4. 88 1
      cli/cmd/commands/app.go
  5. 131 0
      cli/cmd/v2/app_build.go
  6. 11 0
      cli/cmd/v2/app_push.go
  7. 6 1
      cli/cmd/v2/apply.go
  8. 1 1
      go.mod
  9. 2 0
      go.sum

+ 26 - 5
api/client/porter_app.go

@@ -2,6 +2,8 @@ package client
 
 import (
 	"context"
+	"encoding/base64"
+	"encoding/json"
 	"fmt"
 
 	"github.com/porter-dev/porter/api/server/handlers/porter_app"
@@ -531,20 +533,39 @@ func (c *Client) GetAppEnvVariables(
 	return resp, err
 }
 
+// GetBuildFromRevisionInput is the input struct to GetBuildFromRevision
+type GetBuildFromRevisionInput struct {
+	ProjectID       uint
+	ClusterID       uint
+	AppName         string
+	AppRevisionID   string
+	PatchOperations []v2.PatchOperation
+}
+
 // GetBuildFromRevision returns the build environment for a given app proto
 func (c *Client) GetBuildFromRevision(
 	ctx context.Context,
-	projectID uint, clusterID uint,
-	appName string, appRevisionId string,
+	inp GetBuildFromRevisionInput,
 ) (*porter_app.GetBuildFromRevisionResponse, error) {
+	by, err := json.Marshal(inp.PatchOperations)
+	if err != nil {
+		return nil, fmt.Errorf("error marshalling patch operations: %w", err)
+	}
+
+	encoded := base64.StdEncoding.EncodeToString(by)
+
+	req := &porter_app.GetBuildFromRevisionRequest{
+		B64PatchOperations: encoded,
+	}
+
 	resp := &porter_app.GetBuildFromRevisionResponse{}
 
-	err := c.getRequest(
+	err = c.getRequest(
 		fmt.Sprintf(
 			"/projects/%d/clusters/%d/apps/%s/revisions/%s/build",
-			projectID, clusterID, appName, appRevisionId,
+			inp.ProjectID, inp.ClusterID, inp.AppName, inp.AppRevisionID,
 		),
-		nil,
+		req,
 		resp,
 	)
 

+ 47 - 9
api/server/handlers/porter_app/get_build.go

@@ -2,6 +2,7 @@ package porter_app
 
 import (
 	"encoding/base64"
+	"encoding/json"
 	"net/http"
 
 	"github.com/google/uuid"
@@ -17,6 +18,7 @@ import (
 	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/porter_app"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
@@ -54,6 +56,11 @@ type BuildSettings struct {
 	CommitSHA  string   `json:"commit_sha"`
 }
 
+// GetBuildFromRevisionRequest is the request object for the /apps/{porter_app_name}/revisions/{app_revision_id}/build endpoint
+type GetBuildFromRevisionRequest struct {
+	B64PatchOperations string `json:"b64_patch_operations"`
+}
+
 // GetBuildFromRevisionResponse is the response object for the /apps/{porter_app_name}/revisions/{app_revision_id}/build endpoint
 type GetBuildFromRevisionResponse struct {
 	BuildEnvVariables map[string]string `json:"build_env_variables"`
@@ -100,6 +107,30 @@ func (c *GetBuildFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	}
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionUuid.String()})
 
+	request := &GetBuildFromRevisionRequest{}
+	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
+	}
+
+	var patchOps []v2.PatchOperation
+	if request.B64PatchOperations != "" {
+		decodedPatchOps, err := base64.StdEncoding.DecodeString(request.B64PatchOperations)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error decoding patch operations")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		err = json.Unmarshal(decodedPatchOps, &patchOps)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error unmarshalling patch operations")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+	}
+
 	revision, err := porter_app.GetAppRevision(ctx, porter_app.GetAppRevisionInput{
 		AppRevisionID: appRevisionUuid,
 		ProjectID:     project.ID,
@@ -134,23 +165,30 @@ func (c *GetBuildFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
+	patchedProto, err := v2.PatchApp(ctx, appProto, patchOps)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error patching app proto")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	resp.Image = Image{
-		Repository: appProto.Image.Repository,
-		Tag:        appProto.Image.Tag,
+		Repository: patchedProto.Image.Repository,
+		Tag:        patchedProto.Image.Tag,
 	}
 
-	if appProto.Build == nil {
+	if patchedProto.Build == nil {
 		c.WriteResult(w, r, resp)
 		return
 	}
 
 	resp.Build = BuildSettings{
-		Method:     appProto.Build.Method,
-		Context:    appProto.Build.Context,
-		Builder:    appProto.Build.Builder,
-		Buildpacks: appProto.Build.Buildpacks,
-		Dockerfile: appProto.Build.Dockerfile,
-		CommitSHA:  appProto.Build.CommitSha,
+		Method:     patchedProto.Build.Method,
+		Context:    patchedProto.Build.Context,
+		Builder:    patchedProto.Build.Builder,
+		Buildpacks: patchedProto.Build.Buildpacks,
+		Dockerfile: patchedProto.Build.Dockerfile,
+		CommitSHA:  patchedProto.Build.CommitSha,
 	}
 
 	agent, err := c.GetAgent(r, cluster, "")

+ 1 - 1
api/server/router/porter_app.go

@@ -1444,7 +1444,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/build-env -> porter_app.NewGetBuildFromRevisionHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/build -> porter_app.NewGetBuildFromRevisionHandler
 	getBuildFromRevisionEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,

+ 88 - 1
cli/cmd/commands/app.go

@@ -13,9 +13,11 @@ import (
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/commands/flags"
 	"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"
+	appV2 "github.com/porter-dev/porter/internal/porter_app/v2"
 	"github.com/spf13/cobra"
 	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
@@ -69,6 +71,48 @@ func registerCommand_App(cliConf config.CLIConfig) *cobra.Command {
 		"the name of the deployment target for the app",
 	)
 
+	appBuildCommand := &cobra.Command{
+		Use:   "build [application]",
+		Args:  cobra.MinimumNArgs(1),
+		Short: "Builds your application.",
+		Long: fmt.Sprintf(`
+  %s
+
+Builds a new version of the specified app. Attempts to use any build settings
+previously configured for the app, which can be overridden with flags.
+
+If you would like to change the build context, you can do so by using the --build-context flag:
+
+  %s
+
+When using "--method docker", you can specify the path to the Dockerfile using the
+--dockerfile flag. This will also override the Dockerfile path that you may have linked
+for the application:
+
+  %s
+
+To use buildpacks with the "--method pack" flag, you can specify the builder and attach
+buildpacks using the --builder and --attach-buildpacks flags:
+
+	%s
+`,
+			color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter app build\":"),
+			color.New(color.FgGreen, color.Bold).Sprintf("porter app build example --build-context ./app"),
+			color.New(color.FgGreen, color.Bold).Sprintf("porter app build example-app --method docker --dockerfile ./prod.Dockerfile"),
+			color.New(color.FgGreen, color.Bold).Sprintf("porter app build example-app --method pack --builder heroku/buildpacks:20 --attach-buildpacks heroku/nodejs"),
+		),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return checkLoginAndRunWithConfig(cmd, cliConf, args, appBuild)
+		},
+	}
+	flags.UseAppBuildFlags(appBuildCommand)
+	appBuildCommand.PersistentFlags().String(
+		flags.App_ImageTag,
+		"",
+		"set the image tag to use for the build",
+	)
+	appCmd.AddCommand(appBuildCommand)
+
 	// appRunCmd represents the "porter app run" subcommand
 	appRunCmd := &cobra.Command{
 		Use:   "run [application] -- COMMAND [args...]",
@@ -216,6 +260,50 @@ func appRunFlags(appRunCmd *cobra.Command) {
 	)
 }
 
+func appBuild(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	appName := args[0]
+	if appName == "" {
+		return fmt.Errorf("app name must be specified")
+	}
+
+	buildValues, err := flags.AppBuildValuesFromCmd(cmd)
+	if err != nil {
+		return err
+	}
+
+	patchOperations := appV2.PatchOperationsFromFlagValues(appV2.PatchOperationsFromFlagValuesInput{
+		BuildMethod:  buildValues.BuildMethod,
+		Dockerfile:   buildValues.Dockerfile,
+		Builder:      buildValues.Builder,
+		Buildpacks:   buildValues.Buildpacks,
+		BuildContext: buildValues.BuildContext,
+	})
+
+	tag, err := cmd.Flags().GetString(flags.App_ImageTag)
+	if err != nil {
+		return fmt.Errorf("error getting tag: %w", err)
+	}
+
+	err = v2.AppBuild(ctx, v2.AppBuildInput{
+		CLIConfig:            cliConfig,
+		Client:               client,
+		AppName:              appName,
+		DeploymentTargetName: deploymentTargetName,
+		BuildMethod:          buildValues.BuildMethod,
+		Dockerfile:           buildValues.Dockerfile,
+		Builder:              buildValues.Builder,
+		Buildpacks:           buildValues.Buildpacks,
+		BuildContext:         buildValues.BuildContext,
+		ImageTag:             tag,
+		PatchOperations:      patchOperations,
+	})
+	if err != nil {
+		return fmt.Errorf("failed to build app: %w", err)
+	}
+
+	return nil
+}
+
 func appManifests(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, _ *cobra.Command, args []string) error {
 	appName := args[0]
 	if appName == "" {
@@ -370,7 +458,6 @@ func appRun(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client a
 	}
 
 	err = config.setSharedConfig(ctx)
-
 	if err != nil {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}

+ 131 - 0
cli/cmd/v2/app_build.go

@@ -0,0 +1,131 @@
+package v2
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+)
+
+// AppBuildInput is the input to the AppBuild function
+type AppBuildInput struct {
+	// CLIConfig is the CLI configuration
+	CLIConfig config.CLIConfig
+	// Client is the Porter API client
+	Client api.Client
+	// AppName is the name of the app
+	AppName string
+	// DeploymentTargetName is the name of the deployment target, if provided
+	DeploymentTargetName string
+	// BuildMethod is the build method for the app on apply, either 'docker' or 'pack'
+	BuildMethod string
+	// Dockerfile is the path to the Dockerfile when build method is 'docker'
+	Dockerfile string
+	// Builder is the builder to use when build method is 'pack'
+	Builder string
+	// Buildpacks is the buildpacks to use when build method is 'pack'
+	Buildpacks []string
+	// BuildContext is the build context for the app, e.g. ./app
+	BuildContext string
+	// ImageTag is the image tag to use for the app build
+	ImageTag string
+	// PatchOperations is the set of patch operations to apply to the app build
+	PatchOperations []v2.PatchOperation
+}
+
+// AppBuild builds an app using a combination of the provided flag values and build settings from the latest app revision
+func AppBuild(ctx context.Context, inp AppBuildInput) error {
+	cliConf := inp.CLIConfig
+	client := inp.Client
+
+	if cliConf.Project == 0 {
+		return errors.New("project must be set")
+	}
+
+	if cliConf.Cluster == 0 {
+		return errors.New("cluster must be set")
+	}
+
+	latest, err := client.CurrentAppRevision(ctx, api.CurrentAppRevisionInput{
+		ProjectID:            cliConf.Project,
+		ClusterID:            cliConf.Cluster,
+		AppName:              inp.AppName,
+		DeploymentTargetName: inp.DeploymentTargetName,
+	})
+	if err != nil {
+		return fmt.Errorf("error getting latest app revision: %s", err)
+	}
+
+	buildSettings, err := client.GetBuildFromRevision(ctx, api.GetBuildFromRevisionInput{
+		ProjectID:       cliConf.Project,
+		ClusterID:       cliConf.Cluster,
+		AppName:         inp.AppName,
+		AppRevisionID:   latest.AppRevision.ID,
+		PatchOperations: inp.PatchOperations,
+	})
+	if err != nil {
+		return fmt.Errorf("error getting build from revision: %w", err)
+	}
+
+	tagForBuild, err := tagFromCommitSHAOrFlag(inp.ImageTag)
+	if err != nil {
+		return fmt.Errorf("error getting tag for build: %w", err)
+	}
+
+	buildEnvVariables := make(map[string]string)
+	for k, v := range buildSettings.BuildEnvVariables {
+		buildEnvVariables[k] = v
+	}
+
+	// use all env variables from running container in build
+	env := os.Environ()
+	for _, v := range env {
+		pair := strings.SplitN(v, "=", 2)
+		if len(pair) == 2 {
+			if strings.HasPrefix(pair[0], "PORTER_") || strings.HasPrefix(pair[0], "NEXT_PUBLIC_") {
+				buildEnvVariables[pair[0]] = pair[1]
+			}
+		}
+	}
+
+	buildInput, err := buildInputFromBuildSettings(buildInputFromBuildSettingsInput{
+		projectID: cliConf.Project,
+		appName:   inp.AppName,
+		commitSHA: tagForBuild,
+		image:     buildSettings.Image,
+		build:     buildSettings.Build,
+		buildEnv:  buildEnvVariables,
+	})
+	if err != nil {
+		return fmt.Errorf("error creating build input from build settings: %w", err)
+	}
+
+	buildOutput := build(ctx, client, buildInput)
+	if buildOutput.Error != nil {
+		return fmt.Errorf("error building app: %w", buildOutput.Error)
+	}
+
+	color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", tagForBuild) // nolint:errcheck,gosec
+
+	return nil
+}
+
+func tagFromCommitSHAOrFlag(providedTag string) (string, error) {
+	tag := commitSHAFromEnv()
+
+	if providedTag != "" {
+		tag = providedTag
+	}
+
+	if tag == "" {
+		return tag, errors.New("no tag set and could not determine latest commit SHA")
+	}
+
+	return tag, nil
+}

+ 11 - 0
cli/cmd/v2/app_push.go

@@ -0,0 +1,11 @@
+package v2
+
+import "context"
+
+// AppPushInput is the input to the AppPush function
+type AppPushInput struct{}
+
+// AppPush pushes an app to a remote registry
+func AppPush(ctx context.Context, inp AppPushInput) error {
+	return nil
+}

+ 6 - 1
cli/cmd/v2/apply.go

@@ -175,7 +175,12 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 
 	appName := updateResp.AppName
 
-	buildSettings, err := client.GetBuildFromRevision(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+	buildSettings, err := client.GetBuildFromRevision(ctx, api.GetBuildFromRevisionInput{
+		ProjectID:     cliConf.Project,
+		ClusterID:     cliConf.Cluster,
+		AppName:       appName,
+		AppRevisionID: updateResp.AppRevisionId,
+	})
 	if err != nil {
 		return fmt.Errorf("error getting build from revision: %w", err)
 	}

+ 1 - 1
go.mod

@@ -61,7 +61,7 @@ require (
 	k8s.io/helm v2.17.0+incompatible
 	k8s.io/kubectl v0.25.2
 	sigs.k8s.io/aws-iam-authenticator v0.6.1
-	sigs.k8s.io/yaml v1.3.0
+	sigs.k8s.io/yaml v1.4.0
 )
 
 require (

+ 2 - 0
go.sum

@@ -2804,3 +2804,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
 sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
 sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=