diff --git a/cmd/pipectl/BUILD.bazel b/cmd/pipectl/BUILD.bazel index 25c5d9a985..8b4191a675 100644 --- a/cmd/pipectl/BUILD.bazel +++ b/cmd/pipectl/BUILD.bazel @@ -6,7 +6,11 @@ go_library( srcs = ["main.go"], importpath = "github.com/pipe-cd/pipe/cmd/pipectl", visibility = ["//visibility:private"], - deps = ["//pkg/cli:go_default_library"], + deps = [ + "//pkg/app/pipectl/cmd/application:go_default_library", + "//pkg/app/pipectl/cmd/deployment:go_default_library", + "//pkg/cli:go_default_library", + ], ) go_binary( diff --git a/cmd/pipectl/main.go b/cmd/pipectl/main.go index f68c461021..4abd622dc9 100644 --- a/cmd/pipectl/main.go +++ b/cmd/pipectl/main.go @@ -15,8 +15,11 @@ package main import ( - "log" + "fmt" + "os" + "github.com/pipe-cd/pipe/pkg/app/pipectl/cmd/application" + "github.com/pipe-cd/pipe/pkg/app/pipectl/cmd/deployment" "github.com/pipe-cd/pipe/pkg/cli" ) @@ -26,9 +29,13 @@ func main() { "The command line tool for PipeCD.", ) - app.AddCommands() + app.AddCommands( + application.NewCommand(), + deployment.NewCommand(), + ) if err := app.Run(); err != nil { - log.Fatal(err) + fmt.Println("Error:", err) + os.Exit(1) } } diff --git a/pkg/app/pipectl/client/BUILD.bazel b/pkg/app/pipectl/client/BUILD.bazel new file mode 100644 index 0000000000..59cede3073 --- /dev/null +++ b/pkg/app/pipectl/client/BUILD.bazel @@ -0,0 +1,21 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "application.go", + "client.go", + "deployment.go", + ], + importpath = "github.com/pipe-cd/pipe/pkg/app/pipectl/client", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/api/service/apiservice:go_default_library", + "//pkg/model:go_default_library", + "//pkg/rpc/rpcauth:go_default_library", + "//pkg/rpc/rpcclient:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_golang_google_grpc//credentials:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/pipectl/client/application.go b/pkg/app/pipectl/client/application.go new file mode 100644 index 0000000000..6d7e2ec3ae --- /dev/null +++ b/pkg/app/pipectl/client/application.go @@ -0,0 +1,104 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipe/pkg/app/api/service/apiservice" + "github.com/pipe-cd/pipe/pkg/model" +) + +// SyncApplication sents a command to sync a given application and waits until it has been triggered. +// The deployment ID will be returned or an error. +func SyncApplication( + ctx context.Context, + cli apiservice.Client, + appID string, + checkInterval, timeout time.Duration, + logger *zap.Logger, +) (string, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req := &apiservice.SyncApplicationRequest{ + ApplicationId: appID, + } + resp, err := cli.SyncApplication(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to sync application %w", err) + } + + logger.Info("Sent a request to sync application and waiting to be accepted...") + + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + check := func() (deploymentID string, shouldRetry bool) { + const triggeredDeploymentIDKey = "TriggeredDeploymentID" + + cmd, err := getCommand(ctx, cli, resp.CommandId) + if err != nil { + logger.Error(fmt.Sprintf("Failed while retrieving command information. Try again. (%v)", err)) + shouldRetry = true + return + } + + if cmd.Type != model.Command_SYNC_APPLICATION { + logger.Error(fmt.Sprintf("Unexpected command type, want: %s, got: %s", model.Command_SYNC_APPLICATION.String(), cmd.Type.String())) + return + } + + switch cmd.Status { + case model.CommandStatus_COMMAND_SUCCEEDED: + deploymentID = cmd.Metadata[triggeredDeploymentIDKey] + return + + case model.CommandStatus_COMMAND_FAILED: + logger.Error("The request was unable to handle") + return + + case model.CommandStatus_COMMAND_TIMEOUT: + logger.Error("The request was timed out") + return + + default: + shouldRetry = true + return + } + } + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + + case <-ticker.C: + deploymentID, shouldRetry := check() + if shouldRetry { + logger.Info("...") + continue + } + if deploymentID == "" { + return "", fmt.Errorf("failed to detect the triggered deployment ID") + } + return deploymentID, nil + } + } +} diff --git a/pkg/app/pipectl/client/client.go b/pkg/app/pipectl/client/client.go new file mode 100644 index 0000000000..9b37fc61a6 --- /dev/null +++ b/pkg/app/pipectl/client/client.go @@ -0,0 +1,111 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "context" + "crypto/tls" + "errors" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc/credentials" + + "github.com/pipe-cd/pipe/pkg/app/api/service/apiservice" + "github.com/pipe-cd/pipe/pkg/model" + "github.com/pipe-cd/pipe/pkg/rpc/rpcauth" + "github.com/pipe-cd/pipe/pkg/rpc/rpcclient" +) + +type Options struct { + Address string + APIKey string + APIKeyFile string + Insecure bool + CertFile string +} + +func (o *Options) RegisterPersistentFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringVar(&o.Address, "address", o.Address, "The address to control-plane api.") + cmd.PersistentFlags().StringVar(&o.APIKey, "api-key", o.APIKey, "The API key used while authenticating with control-plane.") + cmd.PersistentFlags().StringVar(&o.APIKeyFile, "api-key-file", o.APIKeyFile, "Path to the file containing API key used while authenticating with control-plane.") + cmd.PersistentFlags().BoolVar(&o.Insecure, "insecure", o.Insecure, "Whether disabling transport security while connecting to control-plane.") + cmd.PersistentFlags().StringVar(&o.CertFile, "cert-file", o.CertFile, "The path to the TLS certificate file.") +} + +func (o *Options) Validate() error { + if o.Address == "" { + return errors.New("address must be set") + } + if o.APIKey == "" && o.APIKeyFile == "" { + return errors.New("either api-key or api-key-file must be set") + } + return nil +} + +func (o *Options) NewClient(ctx context.Context) (apiservice.Client, error) { + if err := o.Validate(); err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var creds credentials.PerRPCCredentials + var err error + + if o.APIKey != "" { + creds = rpcclient.NewPerRPCCredentials(o.APIKey, rpcauth.APIKeyCredentials, !o.Insecure) + } else { + creds, err = rpcclient.NewPerRPCCredentialsFromFile(o.APIKeyFile, rpcauth.APIKeyCredentials, !o.Insecure) + if err != nil { + return nil, err + } + } + + options := []rpcclient.DialOption{ + rpcclient.WithBlock(), + rpcclient.WithPerRPCCredentials(creds), + } + + if !o.Insecure { + if o.CertFile != "" { + options = append(options, rpcclient.WithTLS(o.CertFile)) + } else { + config := &tls.Config{} + options = append(options, rpcclient.WithTransportCredentials(credentials.NewTLS(config))) + } + } else { + options = append(options, rpcclient.WithInsecure()) + } + + client, err := apiservice.NewClient(ctx, o.Address, options...) + if err != nil { + return nil, err + } + + return client, nil +} + +func getCommand(ctx context.Context, cli apiservice.Client, cmdID string) (*model.Command, error) { + req := &apiservice.GetCommandRequest{ + CommandId: cmdID, + } + resp, err := cli.GetCommand(ctx, req) + if err != nil { + return nil, err + } + return resp.Command, nil +} diff --git a/pkg/app/pipectl/client/deployment.go b/pkg/app/pipectl/client/deployment.go new file mode 100644 index 0000000000..882b12c10d --- /dev/null +++ b/pkg/app/pipectl/client/deployment.go @@ -0,0 +1,96 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipe/pkg/app/api/service/apiservice" + "github.com/pipe-cd/pipe/pkg/model" +) + +// WaitDeploymentStatuses waits a given deployment until it reaches one of the specified statuses. +func WaitDeploymentStatuses( + ctx context.Context, + cli apiservice.Client, + deploymentID string, + statuses []model.DeploymentStatus, + checkInterval, timeout time.Duration, + logger *zap.Logger, +) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + statusMap := makeDeploymentStatusesMap(statuses) + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + check := func() (status string, shouldRetry bool) { + req := &apiservice.GetDeploymentRequest{ + DeploymentId: deploymentID, + } + resp, err := cli.GetDeployment(ctx, req) + if err != nil { + logger.Error(fmt.Sprintf("Failed while retrieving deployment information. Try again. (%v)", err)) + shouldRetry = true + return + } + + if _, ok := statusMap[resp.Deployment.Status]; !ok { + shouldRetry = true + return + } + + status = strings.TrimPrefix(resp.Deployment.Status.String(), "DEPLOYMENT_") + return + } + + // Do the first check immediately. + status, shouldRetry := check() + if !shouldRetry { + logger.Info(fmt.Sprintf("Deployment is at %s status", status)) + return nil + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case <-ticker.C: + status, shouldRetry := check() + if shouldRetry { + logger.Info("...") + continue + } + + logger.Info(fmt.Sprintf("Deployment is at %s status", status)) + return nil + } + } +} + +func makeDeploymentStatusesMap(statuses []model.DeploymentStatus) map[model.DeploymentStatus]struct{} { + out := make(map[model.DeploymentStatus]struct{}, len(statuses)) + for _, s := range statuses { + out[model.DeploymentStatus(s)] = struct{}{} + } + return out +} diff --git a/pkg/app/pipectl/cmd/application/BUILD.bazel b/pkg/app/pipectl/cmd/application/BUILD.bazel new file mode 100644 index 0000000000..1bab402103 --- /dev/null +++ b/pkg/app/pipectl/cmd/application/BUILD.bazel @@ -0,0 +1,30 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "add.go", + "application.go", + "sync.go", + ], + importpath = "github.com/pipe-cd/pipe/pkg/app/pipectl/cmd/application", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/api/service/apiservice:go_default_library", + "//pkg/app/pipectl/client:go_default_library", + "//pkg/cli:go_default_library", + "//pkg/model:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["sync_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/model:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + ], +) diff --git a/pkg/app/pipectl/cmd/application/add.go b/pkg/app/pipectl/cmd/application/add.go new file mode 100644 index 0000000000..28af7b6b86 --- /dev/null +++ b/pkg/app/pipectl/cmd/application/add.go @@ -0,0 +1,108 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package application + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/pipe-cd/pipe/pkg/app/api/service/apiservice" + "github.com/pipe-cd/pipe/pkg/cli" + "github.com/pipe-cd/pipe/pkg/model" +) + +type add struct { + root *command + + appName string + appKind string + envID string + pipedID string + cloudProvider string + + repoID string + appDir string + configFileName string +} + +func newAddCommand(root *command) *cobra.Command { + c := &add{ + root: root, + configFileName: model.DefaultDeploymentConfigFileName, + } + cmd := &cobra.Command{ + Use: "add", + Short: "Add a new application.", + RunE: cli.WithContext(c.run), + } + + cmd.Flags().StringVar(&c.appName, "app-name", c.appName, "The application name.") + cmd.Flags().StringVar(&c.appKind, "app-kind", c.appKind, "The kind of application. (KUBERNETES|TERRAFORM|LAMBDA|CLOUDRUN)") + cmd.Flags().StringVar(&c.envID, "env-id", c.envID, "The ID of environment where this application should belong to.") + cmd.Flags().StringVar(&c.pipedID, "piped-id", c.pipedID, "The ID of piped that should handle this applicaiton.") + cmd.Flags().StringVar(&c.cloudProvider, "cloud-provider", c.cloudProvider, "The cloud provider name. One of the registered providers in the piped configuration.") + + cmd.Flags().StringVar(&c.repoID, "repo-id", c.repoID, "The repository ID. One the registered repositories in the piped configuration.") + cmd.Flags().StringVar(&c.appDir, "app-dir", c.appDir, "The relative path from the root of repository to the application directory.") + cmd.Flags().StringVar(&c.configFileName, "config-file-name", c.configFileName, "The configuration file name. Default is .pipe.yaml") + + cmd.MarkFlagRequired("app-name") + cmd.MarkFlagRequired("app-kind") + cmd.MarkFlagRequired("env-id") + cmd.MarkFlagRequired("piped-id") + cmd.MarkFlagRequired("cloud-provider") + cmd.MarkFlagRequired("repo-id") + cmd.MarkFlagRequired("app-dir") + + return cmd +} + +func (c *add) run(ctx context.Context, t cli.Telemetry) error { + cli, err := c.root.clientOptions.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to initialize client: %w", err) + } + defer cli.Close() + + appKind, ok := model.ApplicationKind_value[c.appKind] + if !ok { + return fmt.Errorf("unsupported application kind %s", c.appKind) + } + + req := &apiservice.AddApplicationRequest{ + Name: c.appName, + EnvId: c.envID, + PipedId: c.pipedID, + GitPath: &model.ApplicationGitPath{ + Repo: &model.ApplicationGitRepository{ + Id: c.repoID, + }, + Path: c.appDir, + ConfigFilename: c.configFileName, + }, + Kind: model.ApplicationKind(appKind), + CloudProvider: c.cloudProvider, + } + + resp, err := cli.AddApplication(ctx, req) + if err != nil { + return fmt.Errorf("failed to add application: %w", err) + } + + t.Logger.Info(fmt.Sprintf("Successfully added application id = %s", resp.ApplicationId)) + return nil +} diff --git a/pkg/app/pipectl/cmd/application/application.go b/pkg/app/pipectl/cmd/application/application.go new file mode 100644 index 0000000000..a7225832bc --- /dev/null +++ b/pkg/app/pipectl/cmd/application/application.go @@ -0,0 +1,42 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package application + +import ( + "github.com/spf13/cobra" + + "github.com/pipe-cd/pipe/pkg/app/pipectl/client" +) + +type command struct { + clientOptions *client.Options +} + +func NewCommand() *cobra.Command { + c := &command{ + clientOptions: &client.Options{}, + } + cmd := &cobra.Command{ + Use: "application", + Short: "Manage application resources.", + } + + cmd.AddCommand(newAddCommand(c)) + cmd.AddCommand(newSyncCommand(c)) + + c.clientOptions.RegisterPersistentFlags(cmd) + + return cmd +} diff --git a/pkg/app/pipectl/cmd/application/sync.go b/pkg/app/pipectl/cmd/application/sync.go new file mode 100644 index 0000000000..bf9678cb28 --- /dev/null +++ b/pkg/app/pipectl/cmd/application/sync.go @@ -0,0 +1,114 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package application + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/pipe-cd/pipe/pkg/app/pipectl/client" + "github.com/pipe-cd/pipe/pkg/cli" + "github.com/pipe-cd/pipe/pkg/model" +) + +type sync struct { + root *command + + appID string + status []string + checkInterval time.Duration + timeout time.Duration +} + +func newSyncCommand(root *command) *cobra.Command { + c := &sync{ + root: root, + checkInterval: 15 * time.Second, + timeout: 5 * time.Minute, + } + cmd := &cobra.Command{ + Use: "sync", + Short: "Sync an application.", + RunE: cli.WithContext(c.run), + } + + cmd.Flags().StringVar(&c.appID, "app-id", c.appID, "The application ID.") + cmd.Flags().StringSliceVar(&c.status, "wait-status", c.status, fmt.Sprintf("The list of waiting statuses. Empty means returning immediately after triggered. (%s)", strings.Join(availableStatuses(), "|"))) + cmd.Flags().DurationVar(&c.checkInterval, "check-interval", c.checkInterval, "The interval of checking the requested command.") + cmd.Flags().DurationVar(&c.timeout, "timeout", c.timeout, "Maximum execution time.") + + cmd.MarkFlagRequired("app-id") + + return cmd +} + +func (c *sync) run(ctx context.Context, t cli.Telemetry) error { + statuses, err := makeStatuses(c.status) + if err != nil { + return fmt.Errorf("invalid deployment status: %w", err) + } + + cli, err := c.root.clientOptions.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to initialize client: %w", err) + } + defer cli.Close() + + deploymentID, err := client.SyncApplication(ctx, cli, c.appID, c.checkInterval, c.timeout, t.Logger) + if err != nil { + return err + } + + t.Logger.Info(fmt.Sprintf("Successfully triggered deployment %s", deploymentID)) + if len(statuses) == 0 { + return nil + } + + t.Logger.Info("Waiting until the deployment reaches one of the specified statuses") + + return client.WaitDeploymentStatuses( + ctx, + cli, + deploymentID, + statuses, + c.checkInterval, + c.timeout, + t.Logger, + ) +} + +func makeStatuses(statuses []string) ([]model.DeploymentStatus, error) { + out := make([]model.DeploymentStatus, 0, len(statuses)) + for _, s := range statuses { + status, ok := model.DeploymentStatus_value["DEPLOYMENT_"+s] + if !ok { + return nil, fmt.Errorf("bad status %s", s) + } + out = append(out, model.DeploymentStatus(status)) + } + return out, nil +} + +func availableStatuses() []string { + out := make([]string, 0, len(model.DeploymentStatus_value)) + for s := range model.DeploymentStatus_value { + out = append(out, strings.TrimPrefix(s, "DEPLOYMENT_")) + } + return out +} diff --git a/pkg/app/pipectl/cmd/application/sync_test.go b/pkg/app/pipectl/cmd/application/sync_test.go new file mode 100644 index 0000000000..b11e4375f6 --- /dev/null +++ b/pkg/app/pipectl/cmd/application/sync_test.go @@ -0,0 +1,63 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package application + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/pipe-cd/pipe/pkg/model" +) + +func TestMakeStatuses(t *testing.T) { + testcases := []struct { + name string + statuses []string + expected []model.DeploymentStatus + expectedErr bool + }{ + { + name: "empty", + expected: []model.DeploymentStatus{}, + }, + { + name: "has an invalid status", + statuses: []string{"SUCCESS", "INVALID"}, + expectedErr: true, + }, + { + name: "ok", + statuses: []string{"SUCCESS", "PLANNED"}, + expected: []model.DeploymentStatus{ + model.DeploymentStatus_DEPLOYMENT_SUCCESS, + model.DeploymentStatus_DEPLOYMENT_PLANNED, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + statuses, err := makeStatuses(tc.statuses) + assert.Equal(t, tc.expected, statuses) + assert.Equal(t, tc.expectedErr, err != nil) + }) + } +} + +func TestAvailableStatuses(t *testing.T) { + statuses := availableStatuses() + assert.True(t, len(statuses) > 0) +} diff --git a/pkg/app/pipectl/cmd/deployment/BUILD.bazel b/pkg/app/pipectl/cmd/deployment/BUILD.bazel new file mode 100644 index 0000000000..4d8ee5b973 --- /dev/null +++ b/pkg/app/pipectl/cmd/deployment/BUILD.bazel @@ -0,0 +1,28 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "deployment.go", + "waitstatus.go", + ], + importpath = "github.com/pipe-cd/pipe/pkg/app/pipectl/cmd/deployment", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/pipectl/client:go_default_library", + "//pkg/cli:go_default_library", + "//pkg/model:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["waitstatus_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/model:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + ], +) diff --git a/pkg/app/pipectl/cmd/deployment/deployment.go b/pkg/app/pipectl/cmd/deployment/deployment.go new file mode 100644 index 0000000000..0206bc670a --- /dev/null +++ b/pkg/app/pipectl/cmd/deployment/deployment.go @@ -0,0 +1,41 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployment + +import ( + "github.com/spf13/cobra" + + "github.com/pipe-cd/pipe/pkg/app/pipectl/client" +) + +type command struct { + clientOptions *client.Options +} + +func NewCommand() *cobra.Command { + c := &command{ + clientOptions: &client.Options{}, + } + cmd := &cobra.Command{ + Use: "deployment", + Short: "Manage deployment resources.", + } + + cmd.AddCommand(newWaitStatusCommand(c)) + + c.clientOptions.RegisterPersistentFlags(cmd) + + return cmd +} diff --git a/pkg/app/pipectl/cmd/deployment/waitstatus.go b/pkg/app/pipectl/cmd/deployment/waitstatus.go new file mode 100644 index 0000000000..0b072ddaa1 --- /dev/null +++ b/pkg/app/pipectl/cmd/deployment/waitstatus.go @@ -0,0 +1,103 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployment + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/pipe-cd/pipe/pkg/app/pipectl/client" + "github.com/pipe-cd/pipe/pkg/cli" + "github.com/pipe-cd/pipe/pkg/model" +) + +type waitStatus struct { + root *command + + deploymentID string + status []string + checkInterval time.Duration + timeout time.Duration +} + +func newWaitStatusCommand(root *command) *cobra.Command { + c := &waitStatus{ + root: root, + checkInterval: 15 * time.Second, + timeout: 15 * time.Minute, + } + cmd := &cobra.Command{ + Use: "wait-status", + Short: "Wait for one of the specified statuses.", + RunE: cli.WithContext(c.run), + } + + cmd.Flags().StringVar(&c.deploymentID, "deployment-id", c.deploymentID, "The deployment ID.") + cmd.Flags().StringSliceVar(&c.status, "status", c.status, fmt.Sprintf("The list of waiting statuses. (%s)", strings.Join(availableStatuses(), "|"))) + cmd.Flags().DurationVar(&c.checkInterval, "check-interval", c.checkInterval, "The interval of checking the deployment status.") + cmd.Flags().DurationVar(&c.timeout, "timeout", c.timeout, "Maximum execution time.") + + cmd.MarkFlagRequired("deployment-id") + cmd.MarkFlagRequired("status") + + return cmd +} + +func (c *waitStatus) run(ctx context.Context, t cli.Telemetry) error { + statuses, err := makeStatuses(c.status) + if err != nil { + return fmt.Errorf("invalid deployment status: %w", err) + } + + cli, err := c.root.clientOptions.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to initialize client: %w", err) + } + defer cli.Close() + + return client.WaitDeploymentStatuses( + ctx, + cli, + c.deploymentID, + statuses, + c.checkInterval, + c.timeout, + t.Logger, + ) +} + +func makeStatuses(statuses []string) ([]model.DeploymentStatus, error) { + out := make([]model.DeploymentStatus, 0, len(statuses)) + for _, s := range statuses { + status, ok := model.DeploymentStatus_value["DEPLOYMENT_"+s] + if !ok { + return nil, fmt.Errorf("bad status %s", s) + } + out = append(out, model.DeploymentStatus(status)) + } + return out, nil +} + +func availableStatuses() []string { + out := make([]string, 0, len(model.DeploymentStatus_value)) + for s := range model.DeploymentStatus_value { + out = append(out, strings.TrimPrefix(s, "DEPLOYMENT_")) + } + return out +} diff --git a/pkg/app/pipectl/cmd/deployment/waitstatus_test.go b/pkg/app/pipectl/cmd/deployment/waitstatus_test.go new file mode 100644 index 0000000000..59a1fa1217 --- /dev/null +++ b/pkg/app/pipectl/cmd/deployment/waitstatus_test.go @@ -0,0 +1,63 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployment + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/pipe-cd/pipe/pkg/model" +) + +func TestMakeStatuses(t *testing.T) { + testcases := []struct { + name string + statuses []string + expected []model.DeploymentStatus + expectedErr bool + }{ + { + name: "empty", + expected: []model.DeploymentStatus{}, + }, + { + name: "has an invalid status", + statuses: []string{"SUCCESS", "INVALID"}, + expectedErr: true, + }, + { + name: "ok", + statuses: []string{"SUCCESS", "PLANNED"}, + expected: []model.DeploymentStatus{ + model.DeploymentStatus_DEPLOYMENT_SUCCESS, + model.DeploymentStatus_DEPLOYMENT_PLANNED, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + statuses, err := makeStatuses(tc.statuses) + assert.Equal(t, tc.expected, statuses) + assert.Equal(t, tc.expectedErr, err != nil) + }) + } +} + +func TestAvailableStatuses(t *testing.T) { + statuses := availableStatuses() + assert.True(t, len(statuses) > 0) +} diff --git a/pkg/cli/app.go b/pkg/cli/app.go index e4d29e8852..6a09f531eb 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -32,8 +32,9 @@ type App struct { func NewApp(name, desc string) *App { a := &App{ rootCmd: &cobra.Command{ - Use: name, - Short: desc, + Use: name, + Short: desc, + SilenceErrors: true, }, telemetryFlags: defaultTelemetryFlags, } diff --git a/pkg/cli/cmd.go b/pkg/cli/cmd.go index aad5a8ae17..8a99137ea3 100644 --- a/pkg/cli/cmd.go +++ b/pkg/cli/cmd.go @@ -16,7 +16,6 @@ package cli import ( "context" - "fmt" "net/http" "os" "os/signal" @@ -88,7 +87,6 @@ func runWithContext(cmd *cobra.Command, signalCh <-chan os.Signal, runner Runner } }() - logger.Info(fmt.Sprintf("start running %s %s(%s)", service, version.Version, version.BuildDate)) return runner(ctx, telemetry) } diff --git a/pkg/log/log.go b/pkg/log/log.go index 53f8ef3df1..458c78a2a1 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -16,7 +16,6 @@ package log import ( "errors" - "time" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -101,7 +100,6 @@ func newEncoderConfig(encoding EncodingType) zapcore.EncoderConfig { StacktraceKey: "stacktrace", LineEnding: zapcore.DefaultLineEnding, EncodeLevel: encodeLevel, - EncodeTime: HumanizeTimeEncoder, EncodeDuration: zapcore.SecondsDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, } @@ -154,7 +152,3 @@ func (sc ServiceContext) MarshalLogObject(enc zapcore.ObjectEncoder) error { enc.AddString("version", sc.Version) return nil } - -func HumanizeTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { - enc.AppendString(t.Format("15:04:05")) -}