diff --git a/api/client/deployment_target.go b/api/client/deployment_target.go index 0a0d7b2ba3..20a6d7f885 100644 --- a/api/client/deployment_target.go +++ b/api/client/deployment_target.go @@ -4,28 +4,40 @@ import ( "context" "fmt" - "github.com/porter-dev/porter/api/server/handlers/deployment_target" + "github.com/porter-dev/porter/api/types" ) -// CreateDeploymentTarget creates a new deployment target for a given project and cluster with the provided name +// CreateDeploymentTarget creates a deployment target with the given request options func (c *Client) CreateDeploymentTarget( ctx context.Context, - projectID, clusterID uint, - selector string, - preview bool, -) (*deployment_target.CreateDeploymentTargetResponse, error) { - resp := &deployment_target.CreateDeploymentTargetResponse{} - - req := &deployment_target.CreateDeploymentTargetRequest{ - Selector: selector, - Preview: preview, - } + projectId uint, + req *types.CreateDeploymentTargetRequest, +) (*types.CreateDeploymentTargetResponse, error) { + resp := &types.CreateDeploymentTargetResponse{} err := c.postRequest( - fmt.Sprintf( - "/projects/%d/clusters/%d/deployment-targets", - projectID, clusterID, - ), + fmt.Sprintf("/projects/%d/targets", projectId), + req, + resp, + ) + + return resp, err +} + +// ListDeploymentTargets retrieves all deployment targets in a project +func (c *Client) ListDeploymentTargets( + ctx context.Context, + projectId uint, + includePreviews bool, +) (*types.ListDeploymentTargetsResponse, error) { + resp := &types.ListDeploymentTargetsResponse{} + + req := &types.ListDeploymentTargetsRequest{ + Preview: includePreviews, + } + + err := c.getRequest( + fmt.Sprintf("/projects/%d/targets", projectId), req, resp, ) diff --git a/api/server/handlers/deployment_target/create.go b/api/server/handlers/deployment_target/create.go index 9cae7167b2..77e978237d 100644 --- a/api/server/handlers/deployment_target/create.go +++ b/api/server/handlers/deployment_target/create.go @@ -30,34 +30,21 @@ func NewCreateDeploymentTargetHandler( } } -// CreateDeploymentTargetRequest is the request object for the /deployment-targets POST endpoint -type CreateDeploymentTargetRequest struct { - // Deprecated: use name instead - Selector string `json:"selector"` - Name string `json:"name,omitempty"` - Preview bool `json:"preview"` -} - -// CreateDeploymentTargetResponse is the response object for the /deployment-targets POST endpoint -type CreateDeploymentTargetResponse struct { - DeploymentTargetID string `json:"deployment_target_id"` -} - // ServeHTTP handles POST requests to create a new deployment target func (c *CreateDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, span := telemetry.NewSpan(r.Context(), "serve-create-deployment-target") defer span.End() project, _ := ctx.Value(types.ProjectScope).(*models.Project) - cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster) + cluster, clusterOk := ctx.Value(types.ClusterScope).(*models.Cluster) 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.NewErrForbidden(err)) return } - request := &CreateDeploymentTargetRequest{} + request := &types.CreateDeploymentTargetRequest{} 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)) @@ -69,6 +56,16 @@ func (c *CreateDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http return } + clusterId := request.ClusterId + if clusterOk { + clusterId = cluster.ID + } + if clusterId == 0 { + err := telemetry.Error(ctx, span, nil, "cluster id is required") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + name := request.Name if name == "" { name = request.Selector @@ -76,7 +73,7 @@ func (c *CreateDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http createReq := connect.NewRequest(&porterv1.CreateDeploymentTargetRequest{ ProjectId: int64(project.ID), - ClusterId: int64(cluster.ID), + ClusterId: int64(clusterId), Name: name, Namespace: name, IsPreview: request.Preview, @@ -99,7 +96,9 @@ func (c *CreateDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http return } - res := &CreateDeploymentTargetResponse{ + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: ccpResp.Msg.DeploymentTargetId}) + + res := &types.CreateDeploymentTargetResponse{ DeploymentTargetID: ccpResp.Msg.DeploymentTargetId, } diff --git a/api/server/handlers/deployment_target/list.go b/api/server/handlers/deployment_target/list.go index c920dd5e59..21d5a41bbb 100644 --- a/api/server/handlers/deployment_target/list.go +++ b/api/server/handlers/deployment_target/list.go @@ -28,16 +28,6 @@ func NewListDeploymentTargetsHandler( } } -// ListDeploymentTargetsRequest is the request object for the /deployment-targets GET endpoint -type ListDeploymentTargetsRequest struct { - Preview bool `json:"preview"` -} - -// ListDeploymentTargetsResponse is the response object for the /deployment-targets GET endpoint -type ListDeploymentTargetsResponse struct { - DeploymentTargets []types.DeploymentTarget `json:"deployment_targets"` -} - func (c *ListDeploymentTargetsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, span := telemetry.NewSpan(r.Context(), "serve-list-deployment-targets") defer span.End() @@ -45,27 +35,41 @@ func (c *ListDeploymentTargetsHandler) ServeHTTP(w http.ResponseWriter, r *http. project, _ := ctx.Value(types.ProjectScope).(*models.Project) cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster) + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-provided", Value: cluster != nil}) + 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.NewErrForbidden(err)) return } - request := &ListDeploymentTargetsRequest{} + request := &types.ListDeploymentTargetsRequest{} 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 } - deploymentTargets, err := c.Repo().DeploymentTarget().List(project.ID, cluster.ID, request.Preview) - if err != nil { - err := telemetry.Error(ctx, span, err, "error retrieving deployment targets") - c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return + var deploymentTargets []*models.DeploymentTarget + var err error + + if cluster != nil { + deploymentTargets, err = c.Repo().DeploymentTarget().ListForCluster(project.ID, cluster.ID, request.Preview) + if err != nil { + err := telemetry.Error(ctx, span, err, "error retrieving deployment targets for cluster") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + } else { + deploymentTargets, err = c.Repo().DeploymentTarget().List(project.ID, request.Preview) + if err != nil { + err := telemetry.Error(ctx, span, err, "error retrieving deployment targets for project") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } } - response := ListDeploymentTargetsResponse{ + response := types.ListDeploymentTargetsResponse{ DeploymentTargets: make([]types.DeploymentTarget, 0), } diff --git a/api/server/router/project.go b/api/server/router/project.go index 73309042de..bee305f4d5 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -3,6 +3,8 @@ package router import ( "fmt" + "github.com/porter-dev/porter/api/server/handlers/deployment_target" + "github.com/go-chi/chi/v5" apiContract "github.com/porter-dev/porter/api/server/handlers/api_contract" "github.com/porter-dev/porter/api/server/handlers/api_token" @@ -1740,5 +1742,61 @@ func getProjectRoutes( Router: r, }) + // GET /api/projects/{project_id}/targets -> deployment_target.ListDeploymentTargetHandler + listDeploymentTargetEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/targets", relPath), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + listDeploymentTargetHandler := deployment_target.NewListDeploymentTargetsHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: listDeploymentTargetEndpoint, + Handler: listDeploymentTargetHandler, + Router: r, + }) + + // GET /api/projects/{project_id}/targets -> deployment_target.ListDeploymentTargetHandler + createDeploymentTargetEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbCreate, + Method: types.HTTPVerbPost, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/targets", relPath), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + createDeploymentTargetHandler := deployment_target.NewCreateDeploymentTargetHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: createDeploymentTargetEndpoint, + Handler: createDeploymentTargetHandler, + Router: r, + }) + return routes, newPath } diff --git a/api/types/deployment_target.go b/api/types/deployment_target.go index 7db8611749..b98b343ea7 100644 --- a/api/types/deployment_target.go +++ b/api/types/deployment_target.go @@ -19,3 +19,28 @@ type DeploymentTarget struct { CreatedAtUTC time.Time `json:"created_at"` UpdatedAtUTC time.Time `json:"updated_at"` } + +// CreateDeploymentTargetRequest is the request object for the /deployment-targets POST endpoint +type CreateDeploymentTargetRequest struct { + // Deprecated: use name instead + Selector string `json:"selector"` + Name string `json:"name,omitempty"` + Preview bool `json:"preview"` + // required if using the project-scoped endpoint + ClusterId uint `json:"cluster_id"` +} + +// CreateDeploymentTargetResponse is the response object for the /deployment-targets POST endpoint +type CreateDeploymentTargetResponse struct { + DeploymentTargetID string `json:"deployment_target_id"` +} + +// ListDeploymentTargetsRequest is the request object for the /deployment-targets GET endpoint +type ListDeploymentTargetsRequest struct { + Preview bool `json:"preview"` +} + +// ListDeploymentTargetsResponse is the response object for the /deployment-targets GET endpoint +type ListDeploymentTargetsResponse struct { + DeploymentTargets []DeploymentTarget `json:"deployment_targets"` +} diff --git a/cli/cmd/commands/all.go b/cli/cmd/commands/all.go index 5dfb336e18..a76a6d4832 100644 --- a/cli/cmd/commands/all.go +++ b/cli/cmd/commands/all.go @@ -46,6 +46,7 @@ func RegisterCommands() (*cobra.Command, error) { rootCmd.AddCommand(registerCommand_Run(cliConf)) rootCmd.AddCommand(registerCommand_Server(cliConf)) rootCmd.AddCommand(registerCommand_Stack(cliConf)) + rootCmd.AddCommand(registerCommand_Target(cliConf)) rootCmd.AddCommand(registerCommand_Update(cliConf)) rootCmd.AddCommand(registerCommand_Version(cliConf)) rootCmd.AddCommand(registerCommand_Env(cliConf)) diff --git a/cli/cmd/commands/target.go b/cli/cmd/commands/target.go new file mode 100644 index 0000000000..0fe78a347f --- /dev/null +++ b/cli/cmd/commands/target.go @@ -0,0 +1,135 @@ +package commands + +import ( + "context" + "fmt" + "os" + "sort" + "text/tabwriter" + + "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/config" + "github.com/spf13/cobra" +) + +func registerCommand_Target(cliConf config.CLIConfig) *cobra.Command { + targetCmd := &cobra.Command{ + Use: "target", + Aliases: []string{"targets"}, + Short: "Commands that control Porter target settings", + } + + createTargetCmd := &cobra.Command{ + Use: "create --name [name]", + Short: "Creates a deployment target", + Run: func(cmd *cobra.Command, args []string) { + err := checkLoginAndRunWithConfig(cmd, cliConf, args, createTarget) + if err != nil { + os.Exit(1) + } + }, + } + + var targetName string + createTargetCmd.Flags().StringVar(&targetName, "name", "", "Name of deployment target") + targetCmd.AddCommand(createTargetCmd) + + listTargetCmd := &cobra.Command{ + Use: "list", + Short: "Lists the deployment targets for the logged in user", + Long: `Lists the deployment targets in the project + +The following columns are returned: +* ID: id of the deployment target +* NAME: name of the deployment target +* CLUSTER-ID: id of the cluster associated with the deployment target +* DEFAULT: whether the deployment target is the default target for the cluster + +If the --preview flag is set, only deployment targets for preview environments will be returned. +`, + Run: func(cmd *cobra.Command, args []string) { + err := checkLoginAndRunWithConfig(cmd, cliConf, args, listTargets) + if err != nil { + os.Exit(1) + } + }, + } + + var includePreviews bool + listTargetCmd.Flags().BoolVar(&includePreviews, "preview", false, "List preview environments") + targetCmd.AddCommand(listTargetCmd) + + return targetCmd +} + +func createTarget(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error { + targetName, err := cmd.Flags().GetString("name") + if err != nil { + return fmt.Errorf("error finding name flag: %w", err) + } + + resp, err := client.CreateDeploymentTarget(ctx, cliConf.Project, &types.CreateDeploymentTargetRequest{ + Name: targetName, + ClusterId: cliConf.Cluster, + }) + if err != nil { + return err + } + + _, _ = color.New(color.FgGreen).Printf("Created target with name %s and id %s\n", targetName, resp.DeploymentTargetID) + + return nil +} + +func listTargets(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error { + includePreviews, err := cmd.Flags().GetBool("preview") + if err != nil { + return fmt.Errorf("error finding preview flag: %w", err) + } + + resp, err := client.ListDeploymentTargets(ctx, cliConf.Project, includePreviews) + if err != nil { + return err + } + if resp == nil { + return nil + } + + targets := *resp + + sort.Slice(targets.DeploymentTargets, func(i, j int) bool { + if targets.DeploymentTargets[i].ClusterID != targets.DeploymentTargets[j].ClusterID { + return targets.DeploymentTargets[i].ClusterID < targets.DeploymentTargets[j].ClusterID + } + return targets.DeploymentTargets[i].Name < targets.DeploymentTargets[j].Name + }) + + w := new(tabwriter.Writer) + w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight) + + if includePreviews { + fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "CLUSTER-ID") + for _, target := range targets.DeploymentTargets { + fmt.Fprintf(w, "%s\t%s\t%d\n", target.ID, target.Name, target.ClusterID) + } + } else { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "ID", "NAME", "CLUSTER-ID", "DEFAULT") + for _, target := range targets.DeploymentTargets { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", target.ID, target.Name, target.ClusterID, checkmark(target.IsDefault)) + } + } + + _ = w.Flush() + + return nil +} + +func checkmark(b bool) string { + if b { + return "✓" + } + + return "" +} diff --git a/cli/cmd/v2/apply.go b/cli/cmd/v2/apply.go index ccbecf597c..4aaeb32e57 100644 --- a/cli/cmd/v2/apply.go +++ b/cli/cmd/v2/apply.go @@ -375,7 +375,12 @@ func deploymentTargetFromConfig(ctx context.Context, client api.Client, projectI return deploymentTargetID, errors.New("branch name is empty. Please run apply in a git repository with access to the git CLI") } - targetResp, err := client.CreateDeploymentTarget(ctx, projectID, clusterID, branchName, true) + targetResp, err := client.CreateDeploymentTarget(ctx, projectID, &types.CreateDeploymentTargetRequest{ + Selector: "", + Name: branchName, + Preview: true, + ClusterId: clusterID, + }) if err != nil { return deploymentTargetID, fmt.Errorf("error calling create deployment target endpoint: %w", err) } diff --git a/internal/repository/deployment_target.go b/internal/repository/deployment_target.go index 2a41c619d0..adbb20ea13 100644 --- a/internal/repository/deployment_target.go +++ b/internal/repository/deployment_target.go @@ -8,8 +8,10 @@ import ( type DeploymentTargetRepository interface { // DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error) + // ListForCluster returns all deployment targets for a project and cluster + ListForCluster(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) // List returns all deployment targets for a project - List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) + List(projectID uint, preview bool) ([]*models.DeploymentTarget, error) // CreateDeploymentTarget creates a new deployment target CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) // DeploymentTarget retrieves a deployment target by its id if a uuid is provided or by name diff --git a/internal/repository/gorm/deployment_target.go b/internal/repository/gorm/deployment_target.go index 55c84f323d..0757a2ac47 100644 --- a/internal/repository/gorm/deployment_target.go +++ b/internal/repository/gorm/deployment_target.go @@ -32,8 +32,8 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp return deploymentTarget, nil } -// List finds all deployment targets for a given project -func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) { +// ListForCluster finds all deployment targets for a given project +func (repo *DeploymentTargetRepository) ListForCluster(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) { deploymentTargets := []*models.DeploymentTarget{} if err := repo.db.Where("project_id = ? AND cluster_id = ? AND preview = ?", projectID, clusterID, preview).Find(&deploymentTargets).Error; err != nil { return nil, err @@ -42,6 +42,16 @@ func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, pre return deploymentTargets, nil } +// List finds all deployment targets for a given project +func (repo *DeploymentTargetRepository) List(projectID uint, preview bool) ([]*models.DeploymentTarget, error) { + deploymentTargets := []*models.DeploymentTarget{} + if err := repo.db.Where("project_id = ? AND preview = ?", projectID, preview).Find(&deploymentTargets).Error; err != nil { + return nil, err + } + + return deploymentTargets, nil +} + // DeploymentTarget finds all deployment targets for a given project func (repo *DeploymentTargetRepository) DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error) { if deploymentTargetIdentifier == "" { diff --git a/internal/repository/test/deployment_target.go b/internal/repository/test/deployment_target.go index 1ac25253dc..38094eed48 100644 --- a/internal/repository/test/deployment_target.go +++ b/internal/repository/test/deployment_target.go @@ -22,8 +22,13 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp return nil, errors.New("cannot read database") } +// ListForCluster returns all deployment targets for a project +func (repo *DeploymentTargetRepository) ListForCluster(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) { + return nil, errors.New("cannot read database") +} + // List returns all deployment targets for a project -func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) { +func (repo *DeploymentTargetRepository) List(projectID uint, preview bool) ([]*models.DeploymentTarget, error) { return nil, errors.New("cannot read database") }