├── .gitignore ├── .secrets.baseline ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── generate-language-resources ├── bluemix ├── authentication │ ├── auth.go │ ├── auth_test.go │ ├── errors.go │ ├── iam │ │ ├── iam.go │ │ └── iam_test.go │ └── vpc │ │ ├── vpc.go │ │ └── vpc_test.go ├── configuration │ ├── config_helpers │ │ ├── helpers.go │ │ └── helpers_test.go │ ├── core_config │ │ ├── bx_config.go │ │ ├── bx_config_test.go │ │ ├── iam_token.go │ │ ├── iam_token_test.go │ │ └── repository.go │ └── persistor.go ├── crn │ ├── crn.go │ └── crn_test.go ├── endpoints │ ├── endpoints.go │ └── endpoints_test.go ├── env.go ├── env_test.go ├── http │ ├── transport.go │ └── transport_test.go ├── models │ ├── account.go │ ├── authn.go │ ├── endpoints.go │ ├── organizations.go │ ├── pagination.go │ ├── plugin_repo.go │ ├── profile.go │ ├── quotas.go │ ├── region.go │ ├── resource_group.go │ └── spaces.go ├── terminal │ ├── color.go │ ├── prompt.go │ ├── prompt_test.go │ ├── table.go │ ├── table_test.go │ └── ui.go ├── trace │ ├── trace.go │ └── trace_test.go ├── version.go └── version_test.go ├── common ├── downloader │ ├── file_downloader.go │ ├── file_downloader_test.go │ └── progress_bar.go ├── file_helpers │ ├── file.go │ └── gzip.go ├── rest │ ├── client.go │ ├── client_test.go │ ├── helpers │ │ ├── client_helpers.go │ │ └── client_helpers_test.go │ ├── request.go │ └── request_test.go └── types │ └── time.go ├── docs ├── interface_summary.md └── plugin_developer_guide.md ├── go.mod ├── go.sum ├── i18n ├── i18n.go └── resources │ ├── all.de_DE.json │ ├── all.en_US.json │ ├── all.es_ES.json │ ├── all.fr_FR.json │ ├── all.it_IT.json │ ├── all.ja_JP.json │ ├── all.ko_KR.json │ ├── all.pt_BR.json │ ├── all.zh_Hans.json │ └── all.zh_Hant.json ├── plugin ├── plugin.go ├── plugin_config.go ├── plugin_config_test.go ├── plugin_context.go ├── plugin_shim.go ├── plugin_shim_test.go ├── plugin_test.go └── pluginfakes │ ├── fake_plugin.go │ ├── fake_plugin_config.go │ └── fake_plugin_context.go ├── resources └── i18n_resources.go ├── testhelpers ├── command.go ├── configuration │ └── test_config.go ├── matchers │ └── contain_substrings.go └── terminal │ └── test_ui.go └── utils ├── Readme.md ├── new-command.sh ├── project.properties └── setup-project.sh /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/* 2 | !vendor/vendor.json 3 | .vscode -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global repo owners 2 | * @schmidtp0740 @steveclay @boyang9527 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to IBM Cloud CLI SDK 2 | 3 | :+1:First of all, thanks for your time to contribute!:tada: 4 | 5 | The following is a set of guidelines for` contributing to IBM Cloud CLI SDK. If you have any suggestion or issue regarding IBM Cloud CLI, you can go to [ibm-cloud-cli-releases](https://github.com/IBM-Cloud/ibm-cloud-cli-release) and file issues there. 6 | 7 | ## Contribute Code 8 | 9 | ### Before You Submit PR 10 | 11 | #### Code Style 12 | 13 | We follow the offical [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments). Make sure you run [gofmt](https://golang.org/cmd/gofmt/) and [go vet](https://golang.org/cmd/vet/) to fix any major changes. 14 | 15 | #### Unit Test 16 | 17 | Make sure you have good unit test. Run `go test -cover $(go list ./...)`, and ensure coverage is above 80% for major packages (aka packages other than i18n, fakes, docs...). 18 | 19 | #### Secret Detection 20 | This project uses the IBM Detect Secrets Module. Install the module, by following these [instructions](https://github.com/ibm/detect-secrets#installupgrade-module). Once installed, enable the pre-commit secret detection hook by following these [instructions](https://github.com/ibm/detect-secrets#prevention-pre-commit-hook) to ensure no secrets are committed to this repo. 21 | 22 | 23 | #### Commit Message 24 | 25 | Good commit message will greatly help review. We recommend [AngularJS spec of commit message](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit#heading=h.greljkmo14y0). You can use [commitzen](https://github.com/commitizen/cz-cli) to help you compose the commit. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IBM Cloud CLI SDK 2 | 3 | IBM Cloud CLI plugin SDK provides predefined plugin interface, utilities and libraries to develop plugins for [IBM Cloud cli](https://clis.cloud.ibm.com). 4 | 5 | # Get started 6 | 7 | You firstly need [Go](http://www.golang.org) installed on your machine. Then clone this repository into `$GOPATH/src/github.com/IBM-Cloud/ibm-cloud-cli-sdk`. 8 | 9 | This project uses [go modules](https://go.dev/blog/using-go-modules) to manage dependencies. Go to the project directory and run the following command to restore the dependencies into vendor folder: 10 | 11 | ```bash 12 | $ go mod vendor 13 | ``` 14 | 15 | and then run tests: 16 | 17 | ```bash 18 | $ go test ./... 19 | ``` 20 | 21 | # Build and run plugin 22 | 23 | Download and install the IBM Cloud CLI. See instructions [here](https://clis.cloud.ibm.com). 24 | 25 | Compile the plugin source code with `go build` command, for example 26 | 27 | ```bash 28 | $ go build plugin_examples/hello.go 29 | ``` 30 | 31 | Install the plugin: 32 | 33 | ```bash 34 | $ ibmcloud plugin install ./hello 35 | ``` 36 | 37 | List installed plugins: 38 | 39 | ```bash 40 | $ ibmcloud plugin list 41 | 42 | # list plugin commands with '-c' flag 43 | $ ibmcloud plugin list -c 44 | ``` 45 | 46 | Uninstall the plugin: 47 | 48 | ```bash 49 | $ ibmcloud plugin uninstall SayHello # SayHello is the plugin name 50 | ``` 51 | 52 | For more usage of 53 | plugin management, run `ibmcloud help plugin` 54 | 55 | # Develop plugins 56 | 57 | Refer to [plugin developer guide](https://github.com/IBM-Cloud/ibm-cloud-cli-sdk/blob/master/docs/plugin_developer_guide.md) for how to develop a plugin. 58 | 59 | See plugin examples [here](https://github.com/IBM-Cloud/ibm-cloud-cli-sdk/tree/master/plugin_examples) 60 | 61 | # Publish plugins 62 | 63 | IBM Cloud has a public plugin repository by default installed in IBM Cloud CLI. Run `ibmcloud plugin`, you can see a repository named `IBM Cloud` (`https://plugins.cloud.ibm.com`). The repository support multiple version of plugin. You can list all plugins in the repository by using `ibmcloud plugin repo-plugins -r 'IBM Cloud'`. 64 | 65 | To publish, update or remove your plugin in IBM Cloud plugin repository, you can simply [create an issue on GitHub](https://github.com/IBM-Cloud/ibm-cloud-cli-sdk/issues/new) following below samples: 66 | 67 | **Example to publish a new plugin**: 68 | 69 | Title: [plugin-publish] Request to publish a new plugin 'SayHello' 70 | 71 | Content: 72 | 73 | ```yaml 74 | 75 | - name: SayHello 76 | description: Say hello 77 | company: YYY 78 | authors: 79 | - name: xxx 80 | contact: xxx@example.com 81 | homepage: http://www.example.com/hello 82 | version: 0.0.1 83 | binaries: 84 | - platform: osx 85 | url: http://www.example.com/downloads/hello/hello-darwin-amd64-0.0.1 86 | checksum: xxxxx 87 | - platform: win32 88 | url: http://www.example.com/downloads/hello/hello-windows-386-0.0.1.exe 89 | checksum: xxxxx 90 | - platform: win64 91 | url: http://www.example.com/downloads/hello/hello-windows-amd64-0.0.1.exe 92 | checksum: xxxxx 93 | - platform: linux32 94 | url: http://www.example.com/downloads/hello/hello-linux-386-0.0.1.exe 95 | checksum: xxxxx 96 | - platform: linux64 97 | url: http://www.example.com/downloads/hello/hello-linux-amd64-0.0.1.exe 98 | checksum: xxxxx 99 | ``` 100 | 101 | The following descibes each field's usage. 102 | 103 | Field | Description 104 | ------ | --------- 105 | name | Name of your plugin, must not conflict with other existing plugins in the repo. 106 | description | Describe your plugin in a line or two. This description will show up when your plugin is listed on the command line. Avoid saying "A plugin to ..." as it's redundant. Just briefly describe what the plugin provides. 107 | company | *Optional* 108 | authors | authors of the plugin: `name`: name of author; `homepage`: *Optional* link to the homepage of the author; `contact`: *Optional* ways to contact author, email, twitter, phone etc ... 109 | homepage | Link to the homepage 110 | version | Version number of your plugin, in [major].[minor].[build] form 111 | binaries | This section has fields detailing the various binary versions of your plugin. To reach as large an audience as possible, we encourage contributors to cross-compile their plugins on as many platforms as possible. Go provides everything you need to cross-compile for different platforms. `platform`: The os for this binary. Supports `osx`, `linux32`, `linux64`, `win32`, `win64`; `url`: Link to the binary file itself; `checksum`: SHA-1 of the binary file for verification. 112 | 113 | **Example to update a plugin**: 114 | 115 | Title: [plugin-update] Request to update plugin 'SayHello' 116 | 117 | Content: 118 | 119 | ```yaml 120 | 121 | - name: SayHello 122 | description: Updated description of plugin Hello 123 | company: YYY 124 | authors: 125 | - name: xxx 126 | contact: xxx@example.com 127 | homepage: http://www.example.com/hello 128 | ``` 129 | 130 | **Example to remove a plugin**: 131 | 132 | Title: [plugin-remove] Request to remove plugin 'SayHello' 133 | 134 | 135 | **Example to submit/update a version**: 136 | 137 | Title: [plugin-version-update] Request to submit a new version of plugin 'SayHello' 138 | 139 | Content: 140 | 141 | ```yaml 142 | 143 | - name: SayHello 144 | version: 0.0.2 145 | binaries: 146 | - platform: osx 147 | url: http://www.example.com/downloads/hello/hello-darwin-amd64-0.0.2 148 | checksum: xxxxx 149 | - platform: win32 150 | url: http://www.example.com/downloads/hello/hello-windows-386-0.0.2.exe 151 | checksum: xxxxx 152 | - platform: win64 153 | url: http://www.example.com/downloads/hello/hello-windows-amd64-0.0.2.exe 154 | checksum: xxxxx 155 | - platform: linux32 156 | url: http://www.example.com/downloads/hello/hello-linux-386-0.0.2.exe 157 | checksum: xxxxx 158 | - platform: linux64 159 | url: http://www.example.com/downloads/hello/hello-linux-amd64-0.0.2.exe 160 | checksum: xxxxx 161 | ``` 162 | 163 | **Example to remove plugin versions**: 164 | 165 | Title: [plugin-remove] Request to remove plugin 'SayHello' 166 | 167 | Content: 168 | 169 | ```yaml 170 | 171 | - name: SayHello 172 | versions: 173 | - 0.0.1 174 | 0.0.2 175 | ``` 176 | 177 | # Issues 178 | 179 | Report problems by [adding an issue on GitHub](https://github.com/IBM-Cloud/ibm-cloud-cli-sdk/issues/new). 180 | 181 | # License 182 | 183 | This project is released under version 2.0 of the [Apache License](https://github.com/IBM-Cloud/ibm-cloud-cli-sdk/blob/master/LICENSE) 184 | 185 | 186 | 187 | 188 | 189 | -------------------------------------------------------------------------------- /bin/generate-language-resources: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | go get github.com/jteeuwen/go-bindata/... 6 | go-bindata -nometadata -nocompress -pkg resources -o resources/i18n_resources.go i18n/resources/*.json 7 | 8 | # quiet noisy pushd/popd output 9 | pushd resources 1>/dev/null 10 | go fmt ./... 11 | popd 1>/dev/null 12 | -------------------------------------------------------------------------------- /bluemix/authentication/auth.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "net/url" 5 | ) 6 | 7 | type GrantType string 8 | 9 | func (g GrantType) String() string { 10 | return string(g) 11 | } 12 | 13 | type ResponseType string 14 | 15 | func (r ResponseType) String() string { 16 | return string(r) 17 | } 18 | 19 | type TokenRequest struct { 20 | grantType GrantType 21 | params url.Values 22 | responseTypes []ResponseType 23 | } 24 | 25 | func NewTokenRequest(grantType GrantType) *TokenRequest { 26 | return &TokenRequest{ 27 | grantType: grantType, 28 | params: make(url.Values), 29 | } 30 | } 31 | 32 | func (r *TokenRequest) GrantType() GrantType { 33 | return r.grantType 34 | } 35 | 36 | func (r *TokenRequest) ResponseTypes() []ResponseType { 37 | return r.responseTypes 38 | } 39 | 40 | func (r *TokenRequest) GetTokenParam(key string) string { 41 | return r.params.Get(key) 42 | } 43 | 44 | func (r *TokenRequest) SetResponseType(responseTypes ...ResponseType) { 45 | r.responseTypes = responseTypes 46 | } 47 | 48 | func (r *TokenRequest) SetTokenParam(key, value string) { 49 | r.params.Set(key, value) 50 | } 51 | 52 | func (r *TokenRequest) WithOption(opt TokenOption) *TokenRequest { 53 | opt(r) 54 | return r 55 | } 56 | 57 | func (r *TokenRequest) SetValue(v url.Values) { 58 | if r == nil { 59 | panic("authentication: token request is nil") 60 | } 61 | 62 | v.Set("grant_type", r.grantType.String()) 63 | 64 | var responseTypeStr string 65 | for i, t := range r.responseTypes { 66 | if i > 0 { 67 | responseTypeStr += "," 68 | } 69 | responseTypeStr += t.String() 70 | } 71 | v.Set("response_type", responseTypeStr) 72 | 73 | for k, ss := range r.params { 74 | for _, s := range ss { 75 | v.Set(k, s) 76 | } 77 | } 78 | } 79 | 80 | type TokenOption func(r *TokenRequest) 81 | 82 | func SetResponseType(responseTypes ...ResponseType) TokenOption { 83 | return func(r *TokenRequest) { 84 | r.SetResponseType(responseTypes...) 85 | } 86 | } 87 | 88 | func SetTokenParam(key, value string) TokenOption { 89 | return func(r *TokenRequest) { 90 | r.SetTokenParam(key, value) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /bluemix/authentication/auth_test.go: -------------------------------------------------------------------------------- 1 | package authentication_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/authentication" 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/authentication/iam" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetTokenParam(t *testing.T) { 12 | req := authentication.NewTokenRequest(iam.GrantTypeCRToken) 13 | profileParam := "myProfile" 14 | req.SetTokenParam("profile", profileParam) 15 | 16 | parsedParam := req.GetTokenParam("profile") 17 | 18 | assert.Equal(t, profileParam, parsedParam) 19 | } 20 | -------------------------------------------------------------------------------- /bluemix/authentication/errors.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" 5 | ) 6 | 7 | type InvalidTokenError struct { 8 | Description string 9 | } 10 | 11 | func NewInvalidTokenError(description string) *InvalidTokenError { 12 | return &InvalidTokenError{Description: description} 13 | } 14 | 15 | func (e *InvalidTokenError) Error() string { 16 | return T("Invalid token: ") + e.Description 17 | } 18 | 19 | // RefreshTokenExpiryError is an error when provided refresh token expires. This error normally requires 20 | // the client to re-login. 21 | type RefreshTokenExpiryError struct { 22 | Description string 23 | } 24 | 25 | func (e *RefreshTokenExpiryError) Error() string { 26 | return e.Description 27 | } 28 | 29 | // NewRefreshTokenExpiryError creates a RefreshTokenExpiryError 30 | func NewRefreshTokenExpiryError(description string) *RefreshTokenExpiryError { 31 | return &RefreshTokenExpiryError{Description: description} 32 | } 33 | 34 | type ServerError struct { 35 | StatusCode int 36 | ErrorCode string 37 | Description string 38 | } 39 | 40 | func (s *ServerError) Error() string { 41 | return T("Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 42 | map[string]interface{}{"StatusCode": s.StatusCode, "ErrorCode": s.ErrorCode, "Message": s.Description}) 43 | } 44 | 45 | func NewServerError(statusCode int, errorCode string, description string) *ServerError { 46 | return &ServerError{ 47 | StatusCode: statusCode, 48 | ErrorCode: errorCode, 49 | Description: description, 50 | } 51 | } 52 | 53 | type InvalidGrantTypeError struct { 54 | Description string 55 | } 56 | 57 | func NewInvalidGrantTypeError(description string) *InvalidGrantTypeError { 58 | return &InvalidGrantTypeError{Description: description} 59 | } 60 | 61 | func (e *InvalidGrantTypeError) Error() string { 62 | return T("Invalid grant type: ") + e.Description 63 | } 64 | 65 | type ExternalAuthenticationError struct { 66 | ErrorCode string 67 | ErrorMessage string 68 | } 69 | 70 | func (e ExternalAuthenticationError) Error() string { 71 | return T("External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 72 | map[string]interface{}{"ErrorCode": e.ErrorCode, "Message": e.ErrorMessage}) 73 | } 74 | 75 | type SessionInactiveError struct { 76 | Description string 77 | } 78 | 79 | func NewSessionInactiveError(description string) *SessionInactiveError { 80 | return &SessionInactiveError{Description: description} 81 | } 82 | 83 | func (e *SessionInactiveError) Error() string { 84 | return T("Session inactive: ") + e.Description 85 | } 86 | -------------------------------------------------------------------------------- /bluemix/authentication/vpc/vpc.go: -------------------------------------------------------------------------------- 1 | package vpc 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/authentication/iam" 8 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/rest" 9 | ) 10 | 11 | const ( 12 | DefaultMetadataServiceVersion = "2022-03-01" 13 | DefaultServerEndpoint = "http://169.254.169.254" 14 | 15 | defaultMetadataFlavor = "ibm" 16 | defaultInstanceIdentityTokenLifetime = 300 17 | defaultOperationPathCreateAccessToken = "/instance_identity/v1/token" 18 | defaultOperationPathCreateIamToken = "/instance_identity/v1/iam_token" 19 | 20 | // headers 21 | MetadataFlavor = "Metadata-Flavor" 22 | 23 | contentType = "Content-Type" 24 | authorization = "Authorization" 25 | tokenType = "Bearer" 26 | ) 27 | 28 | // InstanceIdentityToken describes the response body for the 'GetInstanceIdentityToken' 29 | // operation. 30 | type InstanceIdentityToken struct { 31 | // The access token 32 | AccessToken string `json:"access_token"` 33 | // The date and time that the access token was created 34 | CreatedAt string `json:"created_at"` 35 | // The date and time that the access token will expire 36 | ExpiresAt string `json:"expires_at"` 37 | // Time in seconds before the access token expires 38 | ExpiresIn int `json:"expires_in"` 39 | } 40 | 41 | // getIAMAccessTokenResponse describes the response body for the 'GetIAMAccessToken' 42 | // operation. 43 | type getIAMAccessTokenResponse struct { 44 | // The access token 45 | AccessToken string `json:"access_token"` 46 | // The date and time that the access token was created 47 | CreatedAt string `json:"created_at"` 48 | // The date and time that the access token will expire 49 | ExpiresAt string `json:"expires_at"` 50 | // Time in seconds before the access token expires 51 | ExpiresIn int `json:"expires_in"` 52 | } 53 | 54 | type Interface interface { 55 | GetInstanceIdentityToken() (*InstanceIdentityToken, error) 56 | GetIAMAccessToken(req *IAMAccessTokenRequest) (*iam.Token, error) 57 | } 58 | 59 | type Config struct { 60 | VPCAuthEndpoint string 61 | InstanceIdentiyTokenEndpoint string 62 | IAMTokenEndpoint string 63 | Version string 64 | } 65 | 66 | // IAMAccessTokenRequest represents the request object for the `GetIAMAccessToken` operation. 67 | // AccessToken - The instance identity token 68 | // Body - The trusted profile ID/CRN represented as the `TrustedProfileByIdOrCRN` object 69 | type IAMAccessTokenRequest struct { 70 | AccessToken string `json:"access_token"` 71 | Body *TrustedProfileByIdOrCRN 72 | } 73 | 74 | type TrustedProfileByIdOrCRN struct { 75 | ID string `json:"id"` 76 | CRN string `json:"crn"` 77 | } 78 | 79 | // NewIAMAccessTokenRequest builds the request body for the GetIAMAccessToken 80 | // operation. The request body for this operation consists of either a trusted profile 81 | // ID or CRN. If both a trusted profile ID and a trusted profile CRN are provided, then 82 | // an error is returned. 83 | func NewIAMAccessTokenRequest(profileID string, profileCRN string, token string) (*IAMAccessTokenRequest, error) { 84 | var trustedProfile TrustedProfileByIdOrCRN 85 | 86 | if profileID != "" && profileCRN != "" { 87 | return nil, fmt.Errorf("A Profile ID and Profile CRN cannot both be specified.") 88 | } 89 | 90 | if profileCRN != "" { 91 | trustedProfile.CRN = profileCRN 92 | } 93 | if profileID != "" { 94 | trustedProfile.ID = profileID 95 | } 96 | 97 | request := &IAMAccessTokenRequest{ 98 | Body: &trustedProfile, 99 | AccessToken: token, 100 | } 101 | 102 | return request, nil 103 | } 104 | 105 | func (c Config) instanceIdentityTokenEndpoint() string { 106 | if c.InstanceIdentiyTokenEndpoint != "" { 107 | return c.InstanceIdentiyTokenEndpoint 108 | } 109 | return c.VPCAuthEndpoint + defaultOperationPathCreateAccessToken 110 | } 111 | 112 | func (c Config) iamTokenEndpoint() string { 113 | if c.IAMTokenEndpoint != "" { 114 | return c.IAMTokenEndpoint 115 | } 116 | return c.VPCAuthEndpoint + defaultOperationPathCreateIamToken 117 | } 118 | 119 | func DefaultConfig(vpcAuthEndpoint string, apiVersion string) Config { 120 | return Config{ 121 | VPCAuthEndpoint: vpcAuthEndpoint, 122 | InstanceIdentiyTokenEndpoint: vpcAuthEndpoint + defaultOperationPathCreateAccessToken, 123 | IAMTokenEndpoint: vpcAuthEndpoint + defaultOperationPathCreateIamToken, 124 | Version: apiVersion, 125 | } 126 | } 127 | 128 | type client struct { 129 | config Config 130 | client *rest.Client 131 | } 132 | 133 | func NewClient(config Config, restClient *rest.Client) Interface { 134 | return &client{ 135 | config: config, 136 | client: restClient, 137 | } 138 | } 139 | 140 | // GetInstanceIdentityToken retrieves the VPC/VSI compute resource's instance identity token specified at 141 | // `c.config.VPCAuthEndpoint` using the "create_access_token" operation of the VPC Instance Metadata Service API. 142 | func (c *client) GetInstanceIdentityToken() (*InstanceIdentityToken, error) { 143 | req := rest.PutRequest(c.config.instanceIdentityTokenEndpoint()) 144 | // set headers 145 | req.Set(MetadataFlavor, defaultMetadataFlavor) 146 | // set query params 147 | req.Query("version", c.config.Version) 148 | 149 | // create body 150 | body := fmt.Sprintf("{\"expires_in\": %d}", defaultInstanceIdentityTokenLifetime) 151 | req.Body(body) 152 | 153 | var tokenResponse struct { 154 | InstanceIdentityToken 155 | } 156 | 157 | _, err := c.client.Do(req, &tokenResponse, nil) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | ret := tokenResponse.InstanceIdentityToken 163 | 164 | return &ret, nil 165 | } 166 | 167 | // GetIAMAccessToken exchanges the VPC/VSI compute resource's instance identity token for an IAM access token at 168 | // `c.config.VPCAuthEndpoint` using the "create_iam_token" operation of the VPC Instance Metadata Service API. 169 | // The request body can consist of a profile CRN or ID, but not both. 170 | func (c *client) GetIAMAccessToken(tokenReq *IAMAccessTokenRequest) (*iam.Token, error) { 171 | req := rest.PostRequest(c.config.iamTokenEndpoint()) 172 | // set instance identity token 173 | req.Set(authorization, "Bearer "+tokenReq.AccessToken) 174 | req.Query("version", c.config.Version) 175 | 176 | if tokenReq.Body != nil { 177 | var body string 178 | profileID := tokenReq.Body.ID 179 | profileCRN := tokenReq.Body.CRN 180 | 181 | if profileID != "" { 182 | body = fmt.Sprintf(`{"trusted_profile": {"id": "%s"}}`, profileID) 183 | } else if profileCRN != "" { 184 | body = fmt.Sprintf(`{"trusted_profile": {"crn": "%s"}}`, profileCRN) 185 | } 186 | 187 | if body != "" { 188 | req.Body(body) 189 | } 190 | 191 | } 192 | 193 | var tokenResponse struct { 194 | getIAMAccessTokenResponse 195 | } 196 | 197 | // error codes have not been defined, so return raw error from server 198 | _, err := c.client.Do(req, &tokenResponse, nil) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | // convert tokenResponse to an IAM token to maintain compatibility 204 | expiry, err := time.Parse(time.RFC3339, tokenResponse.ExpiresAt) 205 | if err != nil { 206 | return nil, fmt.Errorf("Error parsing access token expiration %s", err) 207 | } 208 | iamToken := &iam.Token{ 209 | AccessToken: tokenResponse.AccessToken, 210 | Expiry: expiry, 211 | TokenType: tokenType, 212 | } 213 | 214 | return iamToken, nil 215 | } 216 | -------------------------------------------------------------------------------- /bluemix/configuration/config_helpers/helpers.go: -------------------------------------------------------------------------------- 1 | // Package config_helpers provides helper functions to locate configuration files. 2 | package config_helpers 3 | 4 | import ( 5 | "encoding/base64" 6 | gourl "net/url" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix" 12 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/file_helpers" 13 | ) 14 | 15 | func ConfigDir() string { 16 | if dir := bluemix.EnvConfigDir.Get(); dir != "" { 17 | return dir 18 | } 19 | // TODO: switched to the new default config after all plugin has bumped SDK 20 | if new := defaultConfigDirNew(); file_helpers.FileExists(new) { 21 | return new 22 | } 23 | return defaultConfigDirOld() 24 | } 25 | 26 | func defaultConfigDirNew() string { 27 | return filepath.Join(homeDir(), ".ibmcloud") 28 | } 29 | 30 | func defaultConfigDirOld() string { 31 | return filepath.Join(homeDir(), ".bluemix") 32 | } 33 | 34 | func homeDir() string { 35 | if homeDir := bluemix.EnvConfigHome.Get(); homeDir != "" { 36 | return homeDir 37 | } 38 | return UserHomeDir() 39 | } 40 | 41 | func TempDir() string { 42 | return filepath.Join(ConfigDir(), "tmp") 43 | } 44 | 45 | func ConfigFilePath() string { 46 | return filepath.Join(ConfigDir(), "config.json") 47 | } 48 | 49 | func PluginRepoDir() string { 50 | return filepath.Join(ConfigDir(), "plugins") 51 | } 52 | 53 | func PluginRepoCacheDir() string { 54 | return filepath.Join(PluginRepoDir(), ".cache") 55 | } 56 | 57 | func PluginsConfigFilePath() string { 58 | return filepath.Join(PluginRepoDir(), "config.json") 59 | } 60 | 61 | func PluginDir(pluginName string) string { 62 | return filepath.Join(PluginRepoDir(), pluginName) 63 | } 64 | 65 | func PluginBinaryLocation(pluginName string) string { 66 | executable := filepath.Join(PluginDir(pluginName), pluginName) 67 | if runtime.GOOS == "windows" { 68 | executable = executable + ".exe" 69 | } 70 | return executable 71 | } 72 | 73 | func UserHomeDir() string { 74 | if runtime.GOOS == "windows" { 75 | home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") 76 | if home == "" { 77 | home = os.Getenv("USERPROFILE") 78 | } 79 | return home 80 | } 81 | 82 | return os.Getenv("HOME") 83 | } 84 | 85 | // IsValidPaginationNextURL will return true if the provided nextURL has the expected queries provided 86 | func IsValidPaginationNextURL(nextURL string, cursorQueryParamName string, expectedQueries gourl.Values) bool { 87 | parsedURL, parseErr := gourl.Parse(nextURL) 88 | // NOTE: ignore handling error(s) since if there error(s) 89 | // we can assume the url is invalid 90 | if parseErr != nil { 91 | return false 92 | } 93 | 94 | // retrive encoded cursor 95 | // eg. /api?cursor= 96 | queries := parsedURL.Query() 97 | encodedQuery := queries.Get(cursorQueryParamName) 98 | if encodedQuery == "" { 99 | return false 100 | 101 | } 102 | // decode string and parse encoded queries 103 | decodedQuery, decodedErr := base64.RawURLEncoding.DecodeString(encodedQuery) 104 | if decodedErr != nil { 105 | return false 106 | } 107 | queries, parsedErr := gourl.ParseQuery(string(decodedQuery)) 108 | if parsedErr != nil { 109 | return false 110 | } 111 | 112 | // compare expected queries that should match 113 | // NOTE: assume queries are single value queries. 114 | // if multi-value queries will check the first query 115 | for expectedQuery := range expectedQueries { 116 | paginationQueryValue := queries.Get(expectedQuery) 117 | expectedQueryValue := expectedQueries.Get(expectedQuery) 118 | if paginationQueryValue != expectedQueryValue { 119 | return false 120 | } 121 | 122 | } 123 | 124 | return true 125 | } 126 | -------------------------------------------------------------------------------- /bluemix/configuration/config_helpers/helpers_test.go: -------------------------------------------------------------------------------- 1 | package config_helpers 2 | 3 | import ( 4 | "encoding/base64" 5 | "io/ioutil" 6 | gourl "net/url" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func captureAndPrepareEnv(a *assert.Assertions) ([]string, string) { 16 | env := os.Environ() 17 | 18 | userHome, err := ioutil.TempDir("", "config_dir_test") 19 | a.NoError(err) 20 | 21 | os.Unsetenv("IBMCLOUD_CONFIG_HOME") 22 | os.Unsetenv("IBMCLOUD_HOME") 23 | os.Unsetenv("BLUEMIX_HOME") 24 | os.Setenv("HOME", userHome) 25 | // UserHomeDir() uses HOMEDRIVE + HOMEPATH for windows 26 | if os.Getenv("OS") == "Windows_NT" { 27 | // ioutil.TempDir has the drive letter in the path, so we need to remove it when we set HOMEDRIVE 28 | os.Setenv("HOMEPATH", strings.Replace(userHome, os.Getenv("HOMEDRIVE"), "", -1)) 29 | } 30 | a.NoError(os.RemoveAll(userHome)) 31 | 32 | return env, userHome 33 | } 34 | 35 | func resetEnv(env []string) { 36 | os.Clearenv() 37 | for _, e := range env { 38 | pair := strings.Split(e, "=") 39 | os.Setenv(pair[0], pair[1]) 40 | } 41 | } 42 | 43 | // If $USER_HOME/.ibmcloud does not exist, $USER_HOME/.bluemix should be used 44 | func TestConfigDir_NothingSet_NothingExists(t *testing.T) { 45 | assert := assert.New(t) 46 | 47 | env, userHome := captureAndPrepareEnv(assert) 48 | defer resetEnv(env) 49 | defer os.RemoveAll(userHome) 50 | 51 | // directory should not exist - will use bluemix 52 | assert.Equal(filepath.Join(userHome, ".bluemix"), ConfigDir()) 53 | } 54 | 55 | // If $USER_HOME/.ibmcloud exists, it should be used 56 | func TestConfigDir_NothingSet_IBMCloudExists(t *testing.T) { 57 | assert := assert.New(t) 58 | 59 | env, userHome := captureAndPrepareEnv(assert) 60 | defer resetEnv(env) 61 | defer os.RemoveAll(userHome) 62 | 63 | // create a .ibmcloud directory and it should be returned 64 | ibmcloudDir := filepath.Join(userHome, ".ibmcloud") 65 | assert.NoError(os.MkdirAll(ibmcloudDir, 0700)) 66 | assert.Equal(ibmcloudDir, ConfigDir()) 67 | } 68 | 69 | // If only BLUEMIX_HOME is set, $BLUEMIX_HOME/.bluemix should be used 70 | func TestConfigDir_BluemixHomeSet_NothingExists(t *testing.T) { 71 | assert := assert.New(t) 72 | 73 | env, userHome := captureAndPrepareEnv(assert) 74 | defer resetEnv(env) 75 | defer os.RemoveAll(userHome) 76 | 77 | // if only BLUEMIX_HOME is set, BLUEMIX_HOME is used 78 | os.Setenv("BLUEMIX_HOME", "/my_bluemix_home") 79 | assert.Equal(filepath.Join("/my_bluemix_home", ".bluemix"), ConfigDir()) 80 | } 81 | 82 | // If BLUEMIX_HOME and IBMCLOUD_HOME are set and $IBMCLOUD_HOME/.ibmcloud does not exist, $IBMCLOUD_HOME/.bluemix should be used 83 | func TestConfigDir_BluemixHomesAndIbmCloudHomeSet_NothingExists(t *testing.T) { 84 | assert := assert.New(t) 85 | 86 | env, userHome := captureAndPrepareEnv(assert) 87 | defer resetEnv(env) 88 | defer os.RemoveAll(userHome) 89 | 90 | // if BLUEMIX_HOME and IBMCLOUD_HOME is set, IBMCLOUD_HOME is used 91 | os.Setenv("BLUEMIX_HOME", "/my_bluemix_home") 92 | os.Setenv("IBMCLOUD_HOME", "/my_ibmcloud_home") 93 | assert.Equal(filepath.Join("/my_ibmcloud_home", ".bluemix"), ConfigDir()) 94 | } 95 | 96 | // If IBMCLOUD_CONFIG_HOME is set, $IBMCLOUD_CONFIG_HOME should be used 97 | func TestConfigDir_IbmCloudConfigHomeSet_Exists(t *testing.T) { 98 | assert := assert.New(t) 99 | 100 | env, userHome := captureAndPrepareEnv(assert) 101 | defer resetEnv(env) 102 | defer os.RemoveAll(userHome) 103 | 104 | // if IBMCLOUD_CONFIG_HOME is set and exists, IBMCLOUD_CONFIG_HOME is used 105 | os.Setenv("IBMCLOUD_CONFIG_HOME", userHome) 106 | assert.Equal(userHome, ConfigDir()) 107 | } 108 | 109 | func TestIsValidPaginationNextURL(t *testing.T) { 110 | assert := assert.New(t) 111 | 112 | testCases := []struct { 113 | name string 114 | nextURL string 115 | encodedQueryParam string 116 | expectedQueries gourl.Values 117 | isValid bool 118 | }{ 119 | { 120 | name: "return true for matching expected queries in pagination url", 121 | nextURL: "/api/example?cursor=" + base64.RawURLEncoding.EncodeToString([]byte("limit=100&active=true")), 122 | encodedQueryParam: "cursor", 123 | expectedQueries: gourl.Values{ 124 | "limit": []string{"100"}, 125 | "active": []string{"true"}, 126 | }, 127 | isValid: true, 128 | }, 129 | { 130 | name: "return true for matching expected queries with extraneous queries in pagination url", 131 | nextURL: "/api/example?cursor=" + base64.RawURLEncoding.EncodeToString([]byte("limit=100&active=true&extra=foo")), 132 | encodedQueryParam: "cursor", 133 | expectedQueries: gourl.Values{ 134 | "limit": []string{"100"}, 135 | "active": []string{"true"}, 136 | }, 137 | isValid: true, 138 | }, 139 | { 140 | name: "return false for different limit in pagination url", 141 | nextURL: "/api/example?cursor=" + base64.RawURLEncoding.EncodeToString([]byte("limit=200")), 142 | encodedQueryParam: "cursor", 143 | expectedQueries: gourl.Values{ 144 | "limit": []string{"100"}, 145 | }, 146 | isValid: false, 147 | }, 148 | { 149 | name: "return false for different query among multiple parameters in the pagination url", 150 | nextURL: "/api/example?cursor=" + base64.RawURLEncoding.EncodeToString([]byte("limit=100&active=true")), 151 | encodedQueryParam: "cursor", 152 | expectedQueries: gourl.Values{ 153 | "limit": []string{"100"}, 154 | "active": []string{"false"}, 155 | }, 156 | isValid: false, 157 | }, 158 | } 159 | 160 | for _, tc := range testCases { 161 | t.Run(tc.name, func(_ *testing.T) { 162 | isValid := IsValidPaginationNextURL(tc.nextURL, tc.encodedQueryParam, tc.expectedQueries) 163 | assert.Equal(tc.isValid, isValid) 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /bluemix/configuration/core_config/iam_token.go: -------------------------------------------------------------------------------- 1 | package core_config 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "strings" 7 | "time" 8 | 9 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/types" 10 | ) 11 | 12 | const ( 13 | SubjectTypeServiceID = "ServiceId" 14 | SubjectTypeTrustedProfile = "Profile" 15 | ) 16 | 17 | const expiryDelta = 10 * time.Second 18 | 19 | type IAMTokenInfo struct { 20 | IAMID string `json:"iam_id"` 21 | ID string `json:"id"` 22 | RealmID string `json:"realmid"` 23 | SessionID string `json:"session_id"` 24 | Identifier string `json:"identifier"` 25 | Firstname string `json:"given_name"` 26 | Lastname string `json:"family_name"` 27 | Fullname string `json:"name"` 28 | UserEmail string `json:"email"` 29 | Accounts AccountsInfo `json:"account"` 30 | Subject string `json:"sub"` 31 | SubjectType string `json:"sub_type"` 32 | Issuer string `json:"iss"` 33 | GrantType string `json:"grant_type"` 34 | Scope string `json:"scope"` 35 | Authn Authn `json:"authn"` 36 | Expiry time.Time 37 | IssueAt time.Time 38 | } 39 | 40 | type AccountsInfo struct { 41 | AccountID string `json:"bss"` 42 | IMSAccountID string `json:"ims"` 43 | Valid bool `json:"valid"` 44 | } 45 | 46 | type Authn struct { 47 | Subject string `json:"sub"` 48 | IAMID string `json:"iam_id"` 49 | Name string `json:"name"` 50 | Firstname string `json:"given_name"` 51 | Lastname string `json:"family_name"` 52 | Email string `json:"email"` 53 | } 54 | 55 | func NewIAMTokenInfo(token string) IAMTokenInfo { 56 | tokenJSON, err := DecodeAccessToken(token) 57 | if err != nil { 58 | return IAMTokenInfo{} 59 | } 60 | 61 | var t struct { 62 | IAMTokenInfo 63 | Expiry types.UnixTime `json:"exp"` 64 | IssueAt types.UnixTime `json:"iat"` 65 | } 66 | err = json.Unmarshal(tokenJSON, &t) 67 | if err != nil { 68 | return IAMTokenInfo{} 69 | } 70 | 71 | ret := t.IAMTokenInfo 72 | ret.Expiry = t.Expiry.Time() 73 | ret.IssueAt = t.IssueAt.Time() 74 | return ret 75 | } 76 | 77 | // DecodeAccessToken will decode an access token string into a raw JSON. 78 | // The encoded string is expected to be in three parts separated by a period. 79 | // This method does not validate the contents of the parts 80 | func DecodeAccessToken(token string) (tokenJSON []byte, err error) { 81 | encodedParts := strings.Split(token, ".") 82 | 83 | if len(encodedParts) < 3 { 84 | return 85 | } 86 | 87 | encodedTokenJSON := encodedParts[1] 88 | return base64.RawURLEncoding.DecodeString(encodedTokenJSON) 89 | } 90 | 91 | func (t IAMTokenInfo) exists() bool { 92 | // token without an ID is invalid 93 | return t.ID != "" 94 | } 95 | 96 | func (t IAMTokenInfo) HasExpired() bool { 97 | if !t.exists() { 98 | return true 99 | } 100 | if t.Expiry.IsZero() { 101 | return false 102 | } 103 | return t.Expiry.Before(time.Now().Add(expiryDelta)) 104 | } 105 | -------------------------------------------------------------------------------- /bluemix/configuration/persistor.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "time" 11 | 12 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/file_helpers" 13 | "github.com/gofrs/flock" 14 | ) 15 | 16 | const ( 17 | filePermissions = 0600 18 | dirPermissions = 0700 19 | ) 20 | 21 | type DataInterface interface { 22 | Marshal() ([]byte, error) 23 | Unmarshal([]byte) error 24 | } 25 | 26 | type Persistor interface { 27 | Exists() bool 28 | Load(DataInterface) error 29 | Save(DataInterface) error 30 | } 31 | 32 | type DiskPersistor struct { 33 | filePath string 34 | fileLock *flock.Flock 35 | parentContext context.Context 36 | runtimeGOOS string 37 | } 38 | 39 | func NewDiskPersistor(path string) DiskPersistor { 40 | return DiskPersistor{ 41 | filePath: path, 42 | fileLock: flock.New(path), 43 | parentContext: context.Background(), 44 | runtimeGOOS: runtime.GOOS, 45 | } 46 | } 47 | 48 | func (dp DiskPersistor) Exists() bool { 49 | return file_helpers.FileExists(dp.filePath) 50 | } 51 | 52 | func (dp *DiskPersistor) windowsLockedRead(data DataInterface) error { 53 | // TO DO: exclusive file-locking for the reading NOT yet implemented 54 | return dp.read(data) 55 | } 56 | 57 | func isBlockingLockError(err error) bool { 58 | return err != nil && !strings.Contains(err.Error(), "no such file or directory") 59 | } 60 | 61 | func (dp *DiskPersistor) lockedRead(data DataInterface) error { 62 | lockCtx, cancelLockCtx := context.WithTimeout(dp.parentContext, 30*time.Second) /* allotting a 30-second timeout means there can be a maximum of 298 failed retrials (each up to 500 ms, as 63 | specified after the deferred call to cancelLockCtx). 30 appears to be a conventional value for a parent context passed to TryLockContext, as per docs */ 64 | defer cancelLockCtx() 65 | _, lockErr := dp.fileLock.TryLockContext(lockCtx, 100*time.Millisecond) /* provide a file lock just while dp.read is called, because it calls an unmarshaling function 66 | The boolean (first return value) can be wild-carded because lockErr must be non-nil when the lock-acquiring fails (whereby the boolean will be false) */ 67 | defer dp.fileLock.Unlock() 68 | if isBlockingLockError(lockErr) { 69 | return lockErr 70 | } 71 | readErr := dp.read(data) 72 | if readErr != nil { 73 | return readErr 74 | } 75 | return nil 76 | } 77 | 78 | func (dp DiskPersistor) readWithFileLock(data DataInterface) error { 79 | switch dp.runtimeGOOS { 80 | case "windows": 81 | return dp.windowsLockedRead(data) 82 | default: 83 | return dp.lockedRead(data) 84 | } 85 | } 86 | 87 | func (dp DiskPersistor) writeWithFileLock(data DataInterface) error { 88 | switch dp.runtimeGOOS { 89 | case "windows": 90 | return dp.windowsLockedWrite(data) 91 | default: 92 | return dp.lockedWrite(data) 93 | } 94 | } 95 | 96 | func (dp DiskPersistor) Load(data DataInterface) error { 97 | err := dp.readWithFileLock(data) 98 | if os.IsPermission(err) { 99 | return err 100 | } 101 | 102 | if err != nil { /* would happen if there was nothing to read (EOF) */ 103 | err = dp.writeWithFileLock(data) 104 | } 105 | return err 106 | } 107 | 108 | func (dp *DiskPersistor) windowsLockedWrite(data DataInterface) error { 109 | // TO DO: exclusive file-locking for the writing NOT yet implemented 110 | return dp.write(data) 111 | } 112 | 113 | func (dp DiskPersistor) lockedWrite(data DataInterface) error { 114 | lockCtx, cancelLockCtx := context.WithTimeout(dp.parentContext, 30*time.Second) /* allotting a 30-second timeout means there can be a maximum of 298 failed retrials (each up to 500 ms, as 115 | specified after the deferred call to cancelLockCtx). 30 appears to be a conventional value for a parent context passed to TryLockContext, as per docs */ 116 | defer cancelLockCtx() 117 | _, lockErr := dp.fileLock.TryLockContext(lockCtx, 100*time.Millisecond) /* provide a file lock just while dp.read is called, because it calls an unmarshaling function 118 | The boolean (first return value) can be wild-carded because lockErr must be non-nil when the lock-acquiring fails (whereby the boolean will be false) */ 119 | defer dp.fileLock.Unlock() 120 | if isBlockingLockError(lockErr) { 121 | return lockErr 122 | } 123 | writeErr := dp.write(data) 124 | if writeErr != nil { 125 | return writeErr 126 | } 127 | return nil 128 | } 129 | 130 | func (dp DiskPersistor) Save(data DataInterface) error { 131 | return dp.writeWithFileLock(data) 132 | } 133 | 134 | func (dp DiskPersistor) read(data DataInterface) error { 135 | err := os.MkdirAll(filepath.Dir(dp.filePath), dirPermissions) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | bytes, err := ioutil.ReadFile(dp.filePath) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | err = data.Unmarshal(bytes) 146 | return err 147 | } 148 | 149 | func (dp DiskPersistor) write(data DataInterface) error { 150 | bytes, err := data.Marshal() 151 | if err != nil { 152 | return err 153 | } 154 | 155 | err = ioutil.WriteFile(dp.filePath, bytes, filePermissions) 156 | return err 157 | } 158 | -------------------------------------------------------------------------------- /bluemix/crn/crn.go: -------------------------------------------------------------------------------- 1 | package crn 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | // CRN spec: https://github.ibm.com/ibmcloud/builders-guide/tree/master/specifications/crn 10 | 11 | const ( 12 | crn = "crn" 13 | version = "v1" 14 | 15 | crnSeparator = ":" 16 | scopeSeparator = "/" 17 | ) 18 | 19 | var ( 20 | ErrMalformedCRN = errors.New("malformed CRN") 21 | ErrMalformedScope = errors.New("malformed scope in CRN") 22 | ) 23 | 24 | const ( 25 | ServiceBluemix = "bluemix" 26 | ServiceIAM = "iam" 27 | // more services ... 28 | 29 | ScopeAccount = "a" 30 | ScopeOrganization = "o" 31 | ScopeSpace = "s" 32 | ScopeProject = "p" 33 | 34 | ResourceTypeRole = "role" 35 | ResourceTypeDeployment = "deployment" 36 | // more resources ... 37 | ) 38 | 39 | type CRN struct { 40 | Scheme string 41 | Version string 42 | CName string 43 | CType string 44 | ServiceName string 45 | Region string 46 | ScopeType string 47 | Scope string 48 | ServiceInstance string 49 | ResourceType string 50 | Resource string 51 | } 52 | 53 | func New(cloudName string, cloudType string) CRN { 54 | return CRN{ 55 | Scheme: crn, 56 | Version: version, 57 | CName: cloudName, 58 | CType: cloudType, 59 | } 60 | } 61 | 62 | func (c *CRN) UnmarshalJSON(b []byte) error { 63 | var s string 64 | err := json.Unmarshal(b, &s) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | *c, err = Parse(s) 70 | return err 71 | } 72 | 73 | func (c CRN) MarshalJSON() ([]byte, error) { 74 | return json.Marshal(c.String()) 75 | } 76 | 77 | func Parse(s string) (CRN, error) { 78 | if s == "" { 79 | return CRN{}, nil 80 | } 81 | 82 | segments := strings.Split(s, crnSeparator) 83 | if len(segments) != 10 || segments[0] != crn { 84 | return CRN{}, ErrMalformedCRN 85 | } 86 | 87 | crn := CRN{ 88 | Scheme: segments[0], 89 | Version: segments[1], 90 | CName: segments[2], 91 | CType: segments[3], 92 | ServiceName: segments[4], 93 | Region: segments[5], 94 | ServiceInstance: segments[7], 95 | ResourceType: segments[8], 96 | Resource: segments[9], 97 | } 98 | 99 | scopeSegments := segments[6] 100 | if scopeSegments != "" { 101 | if scopeSegments == "global" { 102 | crn.Scope = "global" 103 | } else { 104 | scopeParts := strings.Split(scopeSegments, scopeSeparator) 105 | if len(scopeParts) == 2 { 106 | crn.ScopeType, crn.Scope = scopeParts[0], scopeParts[1] 107 | } else { 108 | return CRN{}, ErrMalformedScope 109 | } 110 | } 111 | } 112 | 113 | return crn, nil 114 | } 115 | 116 | func (c CRN) String() string { 117 | joinedValue := strings.Join([]string{ 118 | c.Scheme, 119 | c.Version, 120 | c.CName, 121 | c.CType, 122 | c.ServiceName, 123 | c.Region, 124 | c.ScopeSegment(), 125 | c.ServiceInstance, 126 | c.ResourceType, 127 | c.Resource, 128 | }, crnSeparator) 129 | if joinedValue == ":::::::::" { 130 | return "" // do not return a CRN that is just a series of separators, with no string content 131 | } 132 | return joinedValue 133 | } 134 | 135 | func (c CRN) ScopeSegment() string { 136 | if c.ScopeType == "" { 137 | return c.Scope 138 | } 139 | return c.ScopeType + scopeSeparator + c.Scope 140 | } 141 | -------------------------------------------------------------------------------- /bluemix/crn/crn_test.go: -------------------------------------------------------------------------------- 1 | package crn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/suite" 7 | ) 8 | 9 | type CRNTestSuite struct { 10 | suite.Suite 11 | } 12 | 13 | func TestCRNSuite(t *testing.T) { 14 | suite.Run(t, new(CRNTestSuite)) 15 | } 16 | 17 | func (suite *CRNTestSuite) TestParse() { 18 | crnString := "crn:v1:bluemix:public:test-service::a/account-guid::deployment:deployment-guid" 19 | crn, err := Parse(crnString) 20 | suite.NoError(err) 21 | suite.Equal("test-service", crn.ServiceName) 22 | suite.Equal("", crn.Region) 23 | suite.Equal(ScopeAccount, crn.ScopeType) 24 | suite.Equal("account-guid", crn.Scope) 25 | suite.Equal("a/account-guid", crn.ScopeSegment()) 26 | suite.Equal(ResourceTypeDeployment, crn.ResourceType) 27 | suite.Equal("deployment-guid", crn.Resource) 28 | 29 | crnString = "crn:v1:bluemix:public:test-service:us-south:global::deployment:deployment-guid" 30 | crn, err = Parse(crnString) 31 | suite.NoError(err) 32 | suite.Equal("test-service", crn.ServiceName) 33 | suite.Equal("us-south", crn.Region) 34 | suite.Equal("", crn.ScopeType) 35 | suite.Equal("global", crn.Scope) 36 | suite.Equal("global", crn.ScopeSegment()) 37 | suite.Equal(ResourceTypeDeployment, crn.ResourceType) 38 | suite.Equal("deployment-guid", crn.Resource) 39 | 40 | crnString = "crn:v1:bluemix:public:test-service:us-south:error::deployment:deployment-guid" 41 | _, err = Parse(crnString) 42 | suite.Error(err) 43 | } 44 | -------------------------------------------------------------------------------- /bluemix/endpoints/endpoints.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/models" 8 | ) 9 | 10 | type Service string 11 | 12 | const ( 13 | GlobalSearch Service = "global-search" 14 | GlobalTagging Service = "global-tagging" 15 | AccountManagement Service = "account-management" 16 | UserManagement Service = "user-management" 17 | Billing Service = "billing" 18 | Enterprise Service = "enterprise" 19 | ResourceController Service = "resource-controller" 20 | ResourceCatalog Service = "global-catalog" 21 | ) 22 | 23 | func (s Service) String() string { 24 | return string(s) 25 | } 26 | 27 | var endpointsMapping = map[Service]models.Endpoints{ 28 | GlobalSearch: models.Endpoints{ 29 | PublicEndpoint: "https://api.global-search-tagging.", 30 | PrivateEndpoint: "https://api.private..global-search-tagging.", 31 | PrivateVPCEndpoint: "https://api.private.global-search-tagging.", 32 | }, 33 | GlobalTagging: models.Endpoints{ 34 | PublicEndpoint: "https://tags.global-search-tagging.", 35 | PrivateEndpoint: "https://tags.private..global-search-tagging.", 36 | PrivateVPCEndpoint: "https://tags.private.global-search-tagging.", 37 | }, 38 | AccountManagement: models.Endpoints{ 39 | PublicEndpoint: "https://accounts.", 40 | PrivateEndpoint: "https://private..accounts.", 41 | PrivateVPCEndpoint: "https://private.accounts.", 42 | }, 43 | UserManagement: models.Endpoints{ 44 | PublicEndpoint: "https://user-management.", 45 | PrivateEndpoint: "https://private..user-management.", 46 | PrivateVPCEndpoint: "https://private.user-management.", 47 | }, 48 | Billing: models.Endpoints{ 49 | PublicEndpoint: "https://billing.", 50 | PrivateEndpoint: "https://private..billing.", 51 | PrivateVPCEndpoint: "https://private.billing.", 52 | }, 53 | Enterprise: models.Endpoints{ 54 | PublicEndpoint: "https://enterprise.", 55 | PrivateEndpoint: "https://private..enterprise.", 56 | PrivateVPCEndpoint: "https://private.enterprise.", 57 | }, 58 | ResourceController: models.Endpoints{ 59 | PublicEndpoint: "https://resource-controller.", 60 | PrivateEndpoint: "https://private..resource-controller.", 61 | PrivateVPCEndpoint: "https://private.resource-controller.", 62 | }, 63 | ResourceCatalog: models.Endpoints{ 64 | PublicEndpoint: "https://globalcatalog.", 65 | PrivateEndpoint: "https://private..globalcatalog.", 66 | PrivateVPCEndpoint: "https://private.globalcatalog.", 67 | }, 68 | } 69 | 70 | func Endpoint(svc Service, cloudDomain, region string, private, isVPC bool) (string, error) { 71 | var endpoint string 72 | if endpoints, found := endpointsMapping[svc]; found { 73 | if private { 74 | if isVPC { 75 | endpoint = endpoints.PrivateVPCEndpoint 76 | } else { 77 | endpoint = endpoints.PrivateEndpoint 78 | } 79 | } else { 80 | endpoint = endpoints.PublicEndpoint 81 | } 82 | } 83 | if endpoint == "" { 84 | return "", fmt.Errorf("the endpoint of service '%s' was unknown", svc) 85 | } 86 | 87 | // replace 88 | if cloudDomain == "" { 89 | return "", fmt.Errorf("the cloud domain is empty") 90 | } 91 | endpoint = strings.ReplaceAll(endpoint, "", cloudDomain) 92 | 93 | // replace 94 | if region != "" { 95 | endpoint = strings.ReplaceAll(endpoint, "", region) 96 | } else if strings.Index(endpoint, "") >= 0 { 97 | return "", fmt.Errorf("region is required to get the endpoint of service '%s'", svc) 98 | } 99 | 100 | return endpoint, nil 101 | } 102 | -------------------------------------------------------------------------------- /bluemix/endpoints/endpoints_test.go: -------------------------------------------------------------------------------- 1 | package endpoints_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/endpoints" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestEndpointUnknownService(t *testing.T) { 12 | _, err := Endpoint(Service("unknown"), "cloud.ibm.com", "us-south", false, false) 13 | assert.Error(t, err, "an error is expected") 14 | } 15 | 16 | func TestEndpointEmptyCloudDomain(t *testing.T) { 17 | _, err := Endpoint(AccountManagement, "", "", false, false) 18 | assert.Error(t, err, "an error is expected") 19 | } 20 | 21 | func TestEndpointPublic(t *testing.T) { 22 | cloudDomain := "cloud.ibm.com" 23 | region := "" 24 | private := false 25 | isVPC := false 26 | 27 | endpoints := map[Service]string{ 28 | GlobalSearch: fmt.Sprintf("https://api.global-search-tagging.%s", cloudDomain), 29 | GlobalTagging: fmt.Sprintf("https://tags.global-search-tagging.%s", cloudDomain), 30 | AccountManagement: fmt.Sprintf("https://accounts.%s", cloudDomain), 31 | UserManagement: fmt.Sprintf("https://user-management.%s", cloudDomain), 32 | Billing: fmt.Sprintf("https://billing.%s", cloudDomain), 33 | Enterprise: fmt.Sprintf("https://enterprise.%s", cloudDomain), 34 | ResourceController: fmt.Sprintf("https://resource-controller.%s", cloudDomain), 35 | ResourceCatalog: fmt.Sprintf("https://globalcatalog.%s", cloudDomain), 36 | } 37 | for svc, expected := range endpoints { 38 | actual, err := Endpoint(svc, cloudDomain, region, private, isVPC) 39 | assert.NoError(t, err, "public endpoint of service '%s'", svc) 40 | assert.Equal(t, expected, actual, "public endpoint of service '%s'", svc) 41 | } 42 | } 43 | 44 | func TestEndpointPrivate(t *testing.T) { 45 | cloudDomain := "cloud.ibm.com" 46 | region := "us-south" 47 | private := true 48 | isVPC := false 49 | 50 | endpoints := map[Service]string{ 51 | GlobalSearch: fmt.Sprintf("https://api.private.%s.global-search-tagging.%s", region, cloudDomain), 52 | GlobalTagging: fmt.Sprintf("https://tags.private.%s.global-search-tagging.%s", region, cloudDomain), 53 | AccountManagement: fmt.Sprintf("https://private.%s.accounts.%s", region, cloudDomain), 54 | UserManagement: fmt.Sprintf("https://private.%s.user-management.%s", region, cloudDomain), 55 | Billing: fmt.Sprintf("https://private.%s.billing.%s", region, cloudDomain), 56 | Enterprise: fmt.Sprintf("https://private.%s.enterprise.%s", region, cloudDomain), 57 | ResourceController: fmt.Sprintf("https://private.%s.resource-controller.%s", region, cloudDomain), 58 | ResourceCatalog: fmt.Sprintf("https://private.%s.globalcatalog.%s", region, cloudDomain), 59 | } 60 | for svc, expected := range endpoints { 61 | actual, err := Endpoint(svc, cloudDomain, region, private, isVPC) 62 | assert.NoError(t, err, "private endpoint of service '%s'", svc) 63 | assert.Equal(t, expected, actual, "private endpoint of service '%s'", svc) 64 | } 65 | } 66 | 67 | func TestEndpointPrivateNoRegion(t *testing.T) { 68 | _, err := Endpoint(AccountManagement, "cloud.ibm.com", "", true, false) 69 | assert.Error(t, err, "an error is expected") 70 | } 71 | -------------------------------------------------------------------------------- /bluemix/env.go: -------------------------------------------------------------------------------- 1 | package bluemix 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var ( 8 | // EnvTrace is the environment variable `IBMCLOUD_TRACE` and `BLUEMIX_TRACE` (deprecated) 9 | EnvTrace = newEnv("IBMCLOUD_TRACE", "BLUEMIX_TRACE") 10 | // EnvColor is the environment variable `IBMCLOUD_COLOR` and `BLUEMIX_COLOR` (deprecated) 11 | EnvColor = newEnv("IBMCLOUD_COLOR", "BLUEMIX_COLOR") 12 | // EnvVersionCheck is the environment variable `IBMCLOUD_VERSION_CHECK` and `BLUEMIX_VERSION_CHECK` (deprecated) 13 | EnvVersionCheck = newEnv("IBMCLOUD_VERSION_CHECK", "BLUEMIX_VERSION_CHECK") 14 | // EnvAnalytics is the environment variable `IBMCLOUD_ANALYTICS` and `BLUEMIX_ANALYTICS` (deprecated) 15 | EnvAnalytics = newEnv("IBMCLOUD_ANALYTICS", "BLUEMIX_ANALYTICS") 16 | // EnvHTTPTimeout is the environment variable `IBMCLOUD_HTTP_TIMEOUT` and `BLUEMIX_HTTP_TIMEOUT` (deprecated) 17 | EnvHTTPTimeout = newEnv("IBMCLOUD_HTTP_TIMEOUT", "BLUEMIX_HTTP_TIMEOUT") 18 | // EnvAPIKey is the environment variable `IBMCLOUD_API_KEY` and `BLUEMIX_API_KEY` (deprecated) 19 | EnvAPIKey = newEnv("IBMCLOUD_API_KEY", "BLUEMIX_API_KEY") 20 | // EnvCRToken is the environment variable `IBMCLOUD_CR_TOKEN` 21 | EnvCRTokenKey = newEnv("IBMCLOUD_CR_TOKEN") 22 | // EnvCRProfile is the environment variable `IBMCLOUD_CR_PROFILE` 23 | EnvCRProfile = newEnv("IBMCLOUD_CR_PROFILE") 24 | // EnvCRVpcUrl is the environment variable `IBMCLOUD_CR_VPC_URL` 25 | EnvCRVpcUrl = newEnv("IBMCLOUD_CR_VPC_URL") 26 | // EnvConfigHome is the environment variable `IBMCLOUD_HOME` and `BLUEMIX_HOME` (deprecated) 27 | EnvConfigHome = newEnv("IBMCLOUD_HOME", "BLUEMIX_HOME") 28 | // EnvConfigDir is the environment variable `IBMCLOUD_CONFIG_HOME` 29 | EnvConfigDir = newEnv("IBMCLOUD_CONFIG_HOME") 30 | // EnvQuiet is the environment variable `IBMCLOUD_QUIET` 31 | EnvQuiet = newEnv("IBMCLOUD_QUIET") 32 | 33 | // for internal use 34 | EnvCLIName = newEnv("IBMCLOUD_CLI", "BLUEMIX_CLI") 35 | EnvPluginNamespace = newEnv("IBMCLOUD_PLUGIN_NAMESPACE", "BLUEMIX_PLUGIN_NAMESPACE") 36 | ) 37 | 38 | // Env is an environment variable supported by IBM Cloud CLI for specific purpose 39 | // An Env could be bound to multiple environment variables due to historical reasons (i.e. renaming) 40 | // Make sure you define the latest environment variable first 41 | type Env struct { 42 | names []string 43 | } 44 | 45 | // Get will return the value of the environment variable, the first found non-empty value will be returned 46 | func (e Env) Get() string { 47 | for _, n := range e.names { 48 | if v := os.Getenv(n); v != "" { 49 | return v 50 | } 51 | } 52 | return "" 53 | } 54 | 55 | // Set will set the value to **ALL** belonging environment variables 56 | func (e Env) Set(val string) error { 57 | for _, n := range e.names { 58 | if err := os.Setenv(n, val); err != nil { 59 | return err 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | func newEnv(names ...string) Env { 66 | return Env{names: names} 67 | } 68 | -------------------------------------------------------------------------------- /bluemix/env_test.go: -------------------------------------------------------------------------------- 1 | package bluemix_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGet(t *testing.T) { 12 | os.Setenv("IBMCLOUD_TRACE", "true") 13 | assert.Equal(t, "true", EnvTrace.Get()) 14 | 15 | os.Unsetenv("IBMCLOUD_TRACE") 16 | assert.Empty(t, EnvTrace.Get()) 17 | 18 | os.Setenv("BLUEMIX_TRACE", "false") 19 | assert.Equal(t, "false", EnvTrace.Get()) 20 | 21 | os.Unsetenv("BLUEMIX_TRACE") 22 | assert.Empty(t, EnvTrace.Get()) 23 | } 24 | 25 | func TestSet(t *testing.T) { 26 | assert.Empty(t, os.Getenv("IBMCLOUD_COLOR")) 27 | assert.Empty(t, os.Getenv("BLUEMIX_COLOR")) 28 | assert.Empty(t, os.Getenv("IBMCLOUD_CR_TOKEN")) 29 | assert.Empty(t, os.Getenv("IBMCLOUD_CR_PROFILE")) 30 | assert.Empty(t, os.Getenv("IBMCLOUD_CR_VPC_URL")) 31 | assert.Empty(t, EnvColor.Get()) 32 | 33 | EnvColor.Set("true") 34 | assert.Equal(t, "true", os.Getenv("IBMCLOUD_COLOR")) 35 | assert.Equal(t, "true", os.Getenv("BLUEMIX_COLOR")) 36 | assert.Equal(t, "true", EnvColor.Get()) 37 | 38 | EnvCRTokenKey.Set("my_token") 39 | EnvCRProfile.Set("my_profile") 40 | EnvCRVpcUrl.Set("https://someurl.com") 41 | 42 | assert.Equal(t, "my_token", os.Getenv("IBMCLOUD_CR_TOKEN")) 43 | assert.Equal(t, "my_profile", os.Getenv("IBMCLOUD_CR_PROFILE")) 44 | assert.Equal(t, "https://someurl.com", os.Getenv("IBMCLOUD_CR_VPC_URL")) 45 | 46 | os.Unsetenv("IBMCLOUD_CR_TOKEN") 47 | os.Unsetenv("IBMCLOUD_CR_PROFILE") 48 | os.Unsetenv("IBMCLOUD_CR_VPC_URL") 49 | 50 | assert.Empty(t, os.Getenv("IBMCLOUD_CR_TOKEN")) 51 | assert.Empty(t, os.Getenv("IBMCLOUD_CR_PROFILE")) 52 | assert.Empty(t, os.Getenv("IBMCLOUD_CR_VPC_URL")) 53 | } 54 | -------------------------------------------------------------------------------- /bluemix/http/transport.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "strings" 7 | "time" 8 | 9 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 10 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/trace" 11 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" 12 | ) 13 | 14 | // TraceLoggingTransport is a thin wrapper around Transport. 15 | // It dumps HTTP request and response using trace logger, created based on the 16 | // "BLUEMIX_TRACE" environment variable. Sensitive user data will be replaced by 17 | // text "[PRIVATE DATA HIDDEN]". 18 | // 19 | // Example: 20 | // client := &gohttp.Client{ Transport: 21 | // http.NewTraceLoggingTransport(), 22 | // } 23 | // client.Get("http://www.example.com") 24 | type TraceLoggingTransport struct { 25 | rt http.RoundTripper 26 | } 27 | 28 | // NewTraceLoggingTransport creates a TraceLoggingTransport wrapping around 29 | // the passed RoundTripper. If the passed RoundTripper is nil, HTTP 30 | // DefaultTransport is used. 31 | func NewTraceLoggingTransport(rt http.RoundTripper) *TraceLoggingTransport { 32 | if rt == nil { 33 | return &TraceLoggingTransport{ 34 | rt: http.DefaultTransport, 35 | } 36 | } 37 | return &TraceLoggingTransport{ 38 | rt: rt, 39 | } 40 | } 41 | 42 | func (r *TraceLoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { 43 | start := time.Now() 44 | r.dumpRequest(req, start) 45 | resp, err = r.rt.RoundTrip(req) 46 | if err != nil { 47 | return 48 | } 49 | r.dumpResponse(resp, start) 50 | return 51 | } 52 | 53 | func (r *TraceLoggingTransport) dumpRequest(req *http.Request, start time.Time) { 54 | shouldDisplayBody := !strings.Contains(req.Header.Get("Content-Type"), "multipart/form-data") 55 | 56 | dumpedRequest, err := httputil.DumpRequest(req, shouldDisplayBody) 57 | if err != nil { 58 | trace.Logger.Printf(T("An error occurred while dumping request:\n{{.Error}}\n", map[string]interface{}{"Error": err.Error()})) 59 | return 60 | } 61 | 62 | trace.Logger.Printf("\n%s [%s]\n%s\n", 63 | terminal.HeaderColor(T("REQUEST:")), 64 | start.Format(time.RFC3339), 65 | trace.Sanitize(string(dumpedRequest))) 66 | 67 | if !shouldDisplayBody { 68 | trace.Logger.Println("[MULTIPART/FORM-DATA CONTENT HIDDEN]") 69 | } 70 | } 71 | 72 | func (r *TraceLoggingTransport) dumpResponse(res *http.Response, start time.Time) { 73 | end := time.Now() 74 | 75 | shouldDisplayBody := !strings.Contains(res.Header.Get("Content-Type"), "octet-stream") 76 | 77 | dumpedResponse, err := httputil.DumpResponse(res, shouldDisplayBody) 78 | if err != nil { 79 | trace.Logger.Printf(T("An error occurred while dumping response:\n{{.Error}}\n", map[string]interface{}{"Error": err.Error()})) 80 | return 81 | } 82 | 83 | trace.Logger.Printf("\n%s [%s] %s %.0fms\n%s\n", 84 | terminal.HeaderColor(T("RESPONSE:")), 85 | end.Format(time.RFC3339), 86 | terminal.HeaderColor(T("Elapsed:")), 87 | end.Sub(start).Seconds()*1000, 88 | trace.Sanitize(string(dumpedResponse))) 89 | 90 | if !shouldDisplayBody { 91 | trace.Logger.Println("[SKIP BINARY OCTET-STREAM CONTENT]") 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /bluemix/http/transport_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/suite" 12 | 13 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/trace" 14 | ) 15 | 16 | type testLogger struct { 17 | buf bytes.Buffer 18 | } 19 | 20 | func (l *testLogger) Printf(format string, v ...interface{}) { 21 | fmt.Fprintf(&l.buf, format, v...) 22 | } 23 | 24 | func (l *testLogger) Print(v ...interface{}) { 25 | fmt.Fprint(&l.buf, v...) 26 | } 27 | 28 | func (l *testLogger) Println(v ...interface{}) { 29 | fmt.Fprintln(&l.buf, v...) 30 | } 31 | 32 | func (l *testLogger) Clear() { 33 | l.buf.Reset() 34 | } 35 | 36 | func (l *testLogger) Dump() []byte { 37 | return l.buf.Bytes() 38 | } 39 | 40 | type TransportTestSuite struct { 41 | suite.Suite 42 | 43 | client *http.Client 44 | logger *testLogger 45 | 46 | oldTraceLogger trace.Printer 47 | } 48 | 49 | func TestTransportTestSuite(t *testing.T) { 50 | suite.Run(t, new(TransportTestSuite)) 51 | } 52 | 53 | func (suite *TransportTestSuite) SetupSuite() { 54 | suite.oldTraceLogger = trace.Logger 55 | } 56 | 57 | func (suite *TransportTestSuite) TearDownSuite() { 58 | trace.Logger = suite.oldTraceLogger 59 | } 60 | 61 | func (suite *TransportTestSuite) SetupTest() { 62 | suite.logger = new(testLogger) 63 | trace.Logger = suite.logger 64 | suite.client = &http.Client{Transport: NewTraceLoggingTransport(nil)} 65 | } 66 | 67 | func helloHandler(w http.ResponseWriter, r *http.Request) { 68 | fmt.Fprintln(w, "Hello, Client") 69 | 70 | } 71 | 72 | func helloRedirectHandler(w http.ResponseWriter, r *http.Request) { 73 | if r.URL.Path == "/" { 74 | http.Redirect(w, r, "/hello", http.StatusMovedPermanently) 75 | } else { 76 | fmt.Fprintln(w, "Hello, Client") 77 | } 78 | } 79 | 80 | func (suite *TransportTestSuite) TestTraceSimple() { 81 | ts := httptest.NewServer(http.HandlerFunc(helloHandler)) 82 | defer ts.Close() 83 | 84 | expect := []string{ 85 | "REQUEST: ", 86 | "GET / HTTP/1.1", 87 | "Host: " + strings.TrimLeft(ts.URL, "http://"), 88 | 89 | "RESPONSE: ", 90 | "Elapsed: ", 91 | "HTTP/1.1 200 OK", 92 | "Content-Length: 14", 93 | "Content-Type: text/plain; charset=utf-8", 94 | 95 | "Hello, Client", 96 | } 97 | 98 | resp, err := suite.client.Get(ts.URL) 99 | suite.NoError(err) 100 | suite.Equal(http.StatusOK, resp.StatusCode) 101 | for _, e := range expect { 102 | suite.Contains(string(suite.logger.Dump()), e) 103 | } 104 | } 105 | 106 | func (suite *TransportTestSuite) TestTraceRedirect() { 107 | ts := httptest.NewServer(http.HandlerFunc(helloRedirectHandler)) 108 | defer ts.Close() 109 | 110 | host := strings.TrimLeft(ts.URL, "http://") 111 | expect := []string{ 112 | "REQUEST: ", 113 | "GET / HTTP/1.1", 114 | "Host: " + host, 115 | 116 | "RESPONSE: ", 117 | "Elapsed: ", 118 | "HTTP/1.1 301 Moved Permanently", 119 | "Content-Length: 41", 120 | "Content-Type: text/html; charset=utf-8", 121 | "Location: /hello", 122 | "Moved Permanently.", 123 | 124 | // redirected request 125 | "GET /hello HTTP/0.0", 126 | "Host: " + host, 127 | "Referer: " + ts.URL, 128 | 129 | // redirected response 130 | "HTTP/1.1 200 OK", 131 | "Content-Length: 14", 132 | "Content-Type: text/plain; charset=utf-8", 133 | 134 | "Hello, Client", 135 | } 136 | 137 | resp, err := suite.client.Get(ts.URL) 138 | suite.NoError(err) 139 | suite.Equal(http.StatusOK, resp.StatusCode) 140 | for _, e := range expect { 141 | suite.Contains(string(suite.logger.Dump()), e) 142 | } 143 | } 144 | 145 | func (suite *TransportTestSuite) TestTraceApplicationOctetStream() { 146 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 | w.Header().Set("Content-Type", "application/octet-stream") 148 | fmt.Fprintln(w, "Hello, Client") 149 | })) 150 | defer ts.Close() 151 | 152 | expect := []string{ 153 | "REQUEST: ", 154 | "GET / HTTP/1.1", 155 | "Host: " + strings.TrimLeft(ts.URL, "http://"), 156 | 157 | "RESPONSE: ", 158 | "Elapsed: ", 159 | "HTTP/1.1 200 OK", 160 | "Content-Length: 14", 161 | "Content-Type: application/octet-stream", 162 | 163 | "[SKIP BINARY OCTET-STREAM CONTENT]", 164 | } 165 | 166 | resp, err := suite.client.Get(ts.URL) 167 | suite.NoError(err) 168 | suite.Equal(http.StatusOK, resp.StatusCode) 169 | for _, e := range expect { 170 | suite.Contains(string(suite.logger.Dump()), e) 171 | } 172 | } 173 | 174 | func (suite *TransportTestSuite) TestTraceCustomOctetStream() { 175 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 176 | w.Header().Set("Content-Type", "binary/octet-stream") 177 | fmt.Fprintln(w, "Hello, Client") 178 | })) 179 | defer ts.Close() 180 | 181 | expect := []string{ 182 | "REQUEST: ", 183 | "GET / HTTP/1.1", 184 | "Host: " + strings.TrimLeft(ts.URL, "http://"), 185 | 186 | "RESPONSE: ", 187 | "Elapsed: ", 188 | "HTTP/1.1 200 OK", 189 | "Content-Length: 14", 190 | "Content-Type: binary/octet-stream", 191 | 192 | "[SKIP BINARY OCTET-STREAM CONTENT]", 193 | } 194 | 195 | resp, err := suite.client.Get(ts.URL) 196 | suite.NoError(err) 197 | suite.Equal(http.StatusOK, resp.StatusCode) 198 | for _, e := range expect { 199 | suite.Contains(string(suite.logger.Dump()), e) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /bluemix/models/account.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Account struct { 4 | GUID string 5 | Name string 6 | Owner string 7 | } 8 | -------------------------------------------------------------------------------- /bluemix/models/authn.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Authn struct { 4 | Name string 5 | ID string 6 | } 7 | -------------------------------------------------------------------------------- /bluemix/models/endpoints.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Endpoints struct { 4 | PublicEndpoint string 5 | PrivateEndpoint string 6 | PrivateVPCEndpoint string 7 | } 8 | -------------------------------------------------------------------------------- /bluemix/models/organizations.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type OrganizationFields struct { 4 | GUID string 5 | Name string 6 | QuotaDefinition QuotaFields 7 | } 8 | -------------------------------------------------------------------------------- /bluemix/models/pagination.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // ByLastIndex sorts PaginationURLs by LastIndex 4 | type ByLastIndex []PaginationURL 5 | 6 | func (a ByLastIndex) Len() int { return len(a) } 7 | 8 | func (a ByLastIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 9 | 10 | func (a ByLastIndex) Less(i, j int) bool { return a[i].LastIndex < a[j].LastIndex } 11 | 12 | type PaginationURL struct { 13 | LastIndex int 14 | NextURL string 15 | } 16 | -------------------------------------------------------------------------------- /bluemix/models/plugin_repo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type PluginRepo struct { 4 | Name string 5 | URL string 6 | } 7 | -------------------------------------------------------------------------------- /bluemix/models/profile.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Profile struct { 4 | ID string 5 | Name string 6 | ComputeResource Authn 7 | User Authn 8 | } 9 | -------------------------------------------------------------------------------- /bluemix/models/quotas.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type QuotaFields struct { 4 | GUID string `json:"guid,omitempty"` 5 | Name string `json:"name"` 6 | MemoryLimitInMB int64 `json:"memory_limit"` 7 | InstanceMemoryLimitInMB int64 `json:"instance_memory_limit"` 8 | RoutesLimit int `json:"total_routes"` 9 | ServicesLimit int `json:"total_services"` 10 | NonBasicServicesAllowed bool `json:"non_basic_services_allowed"` 11 | AppInstanceLimit int `json:"app_instance_limit"` 12 | } 13 | -------------------------------------------------------------------------------- /bluemix/models/region.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Region struct { 4 | Name string 5 | } 6 | -------------------------------------------------------------------------------- /bluemix/models/resource_group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ResourceGroup struct { 4 | GUID string 5 | Name string 6 | State string 7 | Default bool 8 | QuotaID string 9 | } 10 | -------------------------------------------------------------------------------- /bluemix/models/spaces.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type SpaceFields struct { 4 | GUID string 5 | Name string 6 | AllowSSH bool 7 | } 8 | -------------------------------------------------------------------------------- /bluemix/terminal/color.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | 7 | "golang.org/x/crypto/ssh/terminal" 8 | 9 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix" 10 | "github.com/fatih/color" 11 | colorable "github.com/mattn/go-colorable" 12 | ) 13 | 14 | var ( 15 | Output = colorable.NewColorableStdout() 16 | ErrOutput = colorable.NewColorableStderr() 17 | 18 | TerminalSupportsColors = isTerminal() 19 | UserAskedForColors = "" 20 | ) 21 | 22 | func init() { 23 | InitColorSupport() 24 | } 25 | 26 | func InitColorSupport() { 27 | color.NoColor = !ColorsEnabled() 28 | } 29 | 30 | func ColorsEnabled() bool { 31 | return userDidNotDisableColor() && 32 | (userEnabledColors() || TerminalSupportsColors) 33 | } 34 | 35 | func userEnabledColors() bool { 36 | return UserAskedForColors == "true" || bluemix.EnvColor.Get() == "true" 37 | } 38 | 39 | func userDidNotDisableColor() bool { 40 | colorEnv := bluemix.EnvColor.Get() 41 | return colorEnv != "false" && (UserAskedForColors != "false" || colorEnv == "true") 42 | } 43 | 44 | func Colorize(message string, color *color.Color) string { 45 | return color.Sprint(message) 46 | } 47 | 48 | var decolorizerRegex = regexp.MustCompile(`\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]`) 49 | 50 | func Decolorize(message string) string { 51 | return string(decolorizerRegex.ReplaceAll([]byte(message), []byte(""))) 52 | } 53 | 54 | func HeaderColor(message string) string { 55 | return Colorize(message, color.New(color.Bold)) 56 | } 57 | 58 | func CommandColor(message string) string { 59 | return Colorize(message, color.New(color.FgYellow, color.Bold)) 60 | } 61 | 62 | func StoppedColor(message string) string { 63 | return Colorize(message, color.New(color.FgWhite, color.Bold)) 64 | } 65 | 66 | func AdvisoryColor(message string) string { 67 | return Colorize(message, color.New(color.FgYellow, color.Bold)) 68 | } 69 | 70 | func CrashedColor(message string) string { 71 | return Colorize(message, color.New(color.FgRed, color.Bold)) 72 | } 73 | 74 | func FailureColor(message string) string { 75 | return Colorize(message, color.New(color.FgRed, color.Bold)) 76 | } 77 | 78 | func SuccessColor(message string) string { 79 | return Colorize(message, color.New(color.FgGreen, color.Bold)) 80 | } 81 | 82 | func EntityNameColor(message string) string { 83 | return Colorize(message, color.New(color.FgCyan, color.Bold)) 84 | } 85 | 86 | func PromptColor(message string) string { 87 | return Colorize(message, color.New(color.FgCyan, color.Bold)) 88 | } 89 | 90 | func TableContentHeaderColor(message string) string { 91 | return Colorize(message, color.New(color.FgCyan, color.Bold)) 92 | } 93 | 94 | func WarningColor(message string) string { 95 | return Colorize(message, color.New(color.FgMagenta, color.Bold)) 96 | } 97 | 98 | func LogStdoutColor(message string) string { 99 | return Colorize(message, color.New(color.FgWhite, color.Bold)) 100 | } 101 | 102 | func LogStderrColor(message string) string { 103 | return Colorize(message, color.New(color.FgRed, color.Bold)) 104 | } 105 | 106 | func LogHealthHeaderColor(message string) string { 107 | return Colorize(message, color.New(color.FgWhite, color.Bold)) 108 | } 109 | 110 | func LogAppHeaderColor(message string) string { 111 | return Colorize(message, color.New(color.FgYellow, color.Bold)) 112 | } 113 | 114 | func LogSysHeaderColor(message string) string { 115 | return Colorize(message, color.New(color.FgCyan, color.Bold)) 116 | } 117 | 118 | func isTerminal() bool { 119 | return terminal.IsTerminal(int(os.Stdout.Fd())) 120 | } 121 | -------------------------------------------------------------------------------- /bluemix/terminal/prompt_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type TestData struct { 15 | choices []string 16 | defaultVal interface{} 17 | 18 | input string 19 | 20 | outputContains []string 21 | expected interface{} 22 | err error 23 | 24 | loop bool 25 | } 26 | 27 | func TestStringPrompt(t *testing.T) { 28 | assert := assert.New(t) 29 | 30 | promptTests := []TestData{ 31 | { 32 | input: "foo\n", 33 | expected: "foo", 34 | }, 35 | { 36 | input: "\n", 37 | err: ErrInputEmpty, 38 | }, 39 | { 40 | defaultVal: "", 41 | input: "\n", 42 | expected: "", 43 | }, 44 | { 45 | defaultVal: "bar", 46 | input: "foo\n", 47 | expected: "foo", 48 | }, 49 | { 50 | input: "\nfoo\n", 51 | outputContains: []string{"Please enter value"}, 52 | expected: "foo", 53 | loop: true, 54 | }, 55 | } 56 | 57 | for _, t := range promptTests { 58 | var answer string 59 | testPrompt(assert, t, &answer) 60 | } 61 | } 62 | 63 | func TestBoolPrompt(t *testing.T) { 64 | assert := assert.New(t) 65 | 66 | promptTests := []TestData{ 67 | { 68 | input: "yes\n", 69 | expected: true, 70 | }, 71 | { 72 | input: "Yes\n", 73 | expected: true, 74 | }, 75 | { 76 | input: "y\n", 77 | expected: true, 78 | }, 79 | { 80 | input: "Y\n", 81 | expected: true, 82 | }, 83 | { 84 | input: "no\n", 85 | expected: false, 86 | }, 87 | { 88 | input: "No\n", 89 | expected: false, 90 | }, 91 | { 92 | input: "n\n", 93 | expected: false, 94 | }, 95 | { 96 | input: "N\n", 97 | outputContains: []string{" [y/n]"}, 98 | expected: false, 99 | }, 100 | { 101 | defaultVal: true, 102 | input: "\n", 103 | outputContains: []string{" [Y/n]"}, 104 | expected: true, 105 | }, 106 | { 107 | defaultVal: false, 108 | input: "y\n", 109 | outputContains: []string{" [y/N]"}, 110 | expected: true, 111 | }, 112 | { 113 | input: "NA\n", 114 | err: ErrInputNotBool, 115 | }, 116 | } 117 | 118 | for _, t := range promptTests { 119 | var answer bool 120 | testPrompt(assert, t, &answer) 121 | } 122 | } 123 | 124 | func TestIntPrompt(t *testing.T) { 125 | assert := assert.New(t) 126 | 127 | var intVar int 128 | var int8Var int8 129 | var int16Var int16 130 | var int32Var int32 131 | var int64Var int64 132 | 133 | ints := map[interface{}]interface{}{ 134 | intVar: 100, 135 | int8Var: int8(100), 136 | int16Var: int16(100), 137 | int32Var: int32(100), 138 | int64Var: int64(100), 139 | } 140 | 141 | for acutal, expected := range ints { 142 | testPrompt( 143 | assert, 144 | TestData{ 145 | input: "100\n", 146 | expected: expected, 147 | }, 148 | &acutal) 149 | } 150 | 151 | intTests := []TestData{ 152 | { 153 | input: "NA\n", 154 | err: ErrInputNotNumber, 155 | }, 156 | { 157 | input: "NA\n\n100\n", 158 | outputContains: []string{ 159 | "Please enter a valid number.", 160 | "Please enter value.", 161 | }, 162 | expected: 100, 163 | loop: true, 164 | }, 165 | } 166 | 167 | for _, t := range intTests { 168 | var answer int 169 | testPrompt(assert, t, &answer) 170 | } 171 | } 172 | 173 | func TestFloatPrompt(t *testing.T) { 174 | assert := assert.New(t) 175 | 176 | var floatVar float64 177 | var float32Var float32 178 | 179 | floats := map[interface{}]interface{}{ 180 | floatVar: 3.1415926, 181 | float32Var: float32(3.1415926), 182 | } 183 | 184 | for acutal, expected := range floats { 185 | testPrompt( 186 | assert, 187 | TestData{ 188 | input: "3.1415926\n", 189 | expected: expected, 190 | }, 191 | &acutal) 192 | } 193 | 194 | var answer float64 195 | testPrompt( 196 | assert, 197 | TestData{ 198 | input: "NA\n", 199 | err: ErrInputNotFloatNumber, 200 | }, 201 | &answer) 202 | } 203 | 204 | func TestSinglePrompt_ValidateFunc(t *testing.T) { 205 | assert := assert.New(t) 206 | 207 | var day string 208 | p := NewPrompt("input week day", &PromptOptions{ 209 | ValidateFunc: func(input string) error { 210 | switch strings.ToUpper(input) { 211 | case "SUN": 212 | case "MON": 213 | case "TUE": 214 | case "WED": 215 | case "THU": 216 | case "FRI": 217 | case "SAT": 218 | default: 219 | return errors.New("please input a valid week day (e.g, MON)") 220 | } 221 | return nil 222 | }, 223 | }) 224 | 225 | in := strings.NewReader("wedd\nwed\n") 226 | out := new(bytes.Buffer) 227 | p.Reader = in 228 | p.Writer = out 229 | err := p.Resolve(&day) 230 | 231 | assert.NoError(err) 232 | assert.Contains(out.String(), "please input a valid week day (e.g, MON)") 233 | assert.Equal("wed", day) 234 | } 235 | 236 | func TestReadpasswordNonTTY(t *testing.T) { 237 | assert := assert.New(t) 238 | 239 | in := strings.NewReader("password\n") 240 | out := new(bytes.Buffer) 241 | 242 | p := NewPrompt("Input password", &PromptOptions{HideInput: true}) 243 | p.Reader = in 244 | p.Writer = out 245 | 246 | var passwd string 247 | err := p.Resolve(&passwd) 248 | 249 | assert.NoError(err) 250 | assert.Equal("password", passwd) 251 | assert.NotContains("password", out.String()) 252 | } 253 | 254 | func TestChoicesPrompt2(t *testing.T) { 255 | assert := assert.New(t) 256 | 257 | choices := []string{"foo", "bar"} 258 | choicesPromptTests := []TestData{ 259 | { 260 | choices: choices, 261 | input: "1\n", 262 | outputContains: []string{ 263 | "1. foo", 264 | "2. bar", 265 | "Enter a number", 266 | }, 267 | expected: "foo", 268 | }, 269 | { 270 | choices: choices, 271 | defaultVal: "bar", 272 | input: "\n", 273 | outputContains: []string{ 274 | "Enter a number (2)", 275 | }, 276 | expected: "bar", 277 | }, 278 | { 279 | choices: choices, 280 | defaultVal: "jar", 281 | input: "\n", 282 | outputContains: []string{ 283 | "Enter a number ()", 284 | }, 285 | expected: "jar", 286 | }, 287 | { 288 | choices: choices, 289 | input: "\n", 290 | err: ErrInputEmpty, 291 | }, 292 | { 293 | choices: choices, 294 | input: "NA\n3\n2\n", 295 | outputContains: []string{ 296 | "Please enter a number between 1 to 2", 297 | }, 298 | expected: "bar", 299 | loop: true, 300 | }, 301 | } 302 | 303 | for _, t := range choicesPromptTests { 304 | var answer string 305 | testPrompt(assert, t, &answer) 306 | } 307 | } 308 | 309 | func testPrompt(assert *assert.Assertions, d TestData, dest interface{}) { 310 | e := reflect.ValueOf(dest).Elem() 311 | if d.defaultVal != nil { 312 | e.Set(reflect.ValueOf(d.defaultVal)) 313 | } 314 | 315 | var p *Prompt 316 | var msg string 317 | options := &PromptOptions{NoLoop: !d.loop, Required: (d.defaultVal == nil)} 318 | if len(d.choices) == 0 { 319 | p = NewPrompt("input something", options) 320 | msg = fmt.Sprintf("Prompt(%s): input: %q, default: %v", e.Type(), d.input, d.defaultVal) 321 | } else { 322 | p = NewChoicesPrompt("select an item", d.choices, options) 323 | msg = fmt.Sprintf("choices prompt[%s]: input: %q, default: %v", strings.Join(d.choices, ","), d.input, d.defaultVal) 324 | } 325 | 326 | in := strings.NewReader(d.input) 327 | out := new(bytes.Buffer) 328 | 329 | p.Reader = in 330 | p.Writer = out 331 | err := p.Resolve(dest) 332 | 333 | for _, s := range d.outputContains { 334 | assert.Contains(out.String(), s, msg) 335 | } 336 | 337 | if d.err != nil { 338 | assert.Equal(d.err, err, msg) 339 | return 340 | } 341 | assert.NoError(err, msg) 342 | assert.Equal(d.expected, e.Interface(), msg) 343 | } 344 | -------------------------------------------------------------------------------- /bluemix/terminal/table.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "golang.org/x/term" 11 | 12 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" 13 | 14 | "io" 15 | 16 | "github.com/jedib0t/go-pretty/v6/table" 17 | "github.com/jedib0t/go-pretty/v6/text" 18 | "github.com/mattn/go-runewidth" 19 | ) 20 | 21 | const ( 22 | // minSpace is the number of spaces between the end of the longest value and 23 | // the start of the next row 24 | minSpace = 3 25 | ) 26 | 27 | type Table interface { 28 | Add(row ...string) 29 | Print() 30 | PrintJson() 31 | PrintCsv() error 32 | } 33 | 34 | type PrintableTable struct { 35 | writer io.Writer 36 | headers []string 37 | maxSizes []int 38 | rows [][]string //each row is single line 39 | } 40 | 41 | func NewTable(w io.Writer, headers []string) Table { 42 | return &PrintableTable{ 43 | writer: w, 44 | headers: headers, 45 | maxSizes: make([]int, len(headers)), 46 | } 47 | } 48 | 49 | func (t *PrintableTable) Add(row ...string) { 50 | var maxLines int 51 | 52 | var columns [][]string 53 | for _, value := range row { 54 | lines := strings.Split(value, "\n") 55 | if len(lines) > maxLines { 56 | maxLines = len(lines) 57 | } 58 | columns = append(columns, lines) 59 | } 60 | 61 | for i := 0; i < maxLines; i++ { 62 | var row []string 63 | for _, col := range columns { 64 | if i >= len(col) { 65 | row = append(row, "") 66 | } else { 67 | row = append(row, col[i]) 68 | } 69 | } 70 | t.rows = append(t.rows, row) 71 | } 72 | 73 | // Incase we have more columns in a row than headers, need to update maxSizes 74 | if len(row) > len(t.maxSizes) { 75 | t.maxSizes = make([]int, len(row)) 76 | } 77 | } 78 | 79 | func isWideColumn(col string) bool { 80 | // list of common columns that are usually wide 81 | largeColumnTypes := []string{T("ID"), T("Description")} 82 | 83 | for _, largeColn := range largeColumnTypes { 84 | if strings.Contains(largeColn, col) { 85 | return true 86 | } 87 | } 88 | 89 | return false 90 | 91 | } 92 | 93 | func terminalWidth() int { 94 | var err error 95 | terminalWidth, _, err := term.GetSize(int(os.Stdin.Fd())) 96 | 97 | if err != nil { 98 | // Assume normal 80 char width line 99 | terminalWidth = 80 100 | } 101 | 102 | testTerminalWidth, envSet := os.LookupEnv("TEST_TERMINAL_WIDTH") 103 | if envSet { 104 | envWidth, err := strconv.Atoi(testTerminalWidth) 105 | if err == nil { 106 | terminalWidth = envWidth 107 | } 108 | } 109 | return terminalWidth 110 | } 111 | 112 | func (t *PrintableTable) Print() { 113 | for _, row := range append(t.rows, t.headers) { 114 | t.calculateMaxSize(row) 115 | } 116 | 117 | tbl := table.NewWriter() 118 | tbl.SetOutputMirror(t.writer) 119 | tbl.SuppressTrailingSpaces() 120 | // remove padding from the left to keep the table aligned to the left 121 | tbl.Style().Box.PaddingLeft = "" 122 | tbl.Style().Box.PaddingRight = strings.Repeat(" ", minSpace) 123 | // remove all border and column and row separators 124 | tbl.Style().Options.DrawBorder = false 125 | tbl.Style().Options.SeparateColumns = false 126 | tbl.Style().Options.SeparateFooter = false 127 | tbl.Style().Options.SeparateHeader = false 128 | tbl.Style().Options.SeparateRows = false 129 | tbl.Style().Format.Header = text.FormatDefault 130 | 131 | headerRow, rows := t.createPrettyRowsAndHeaders() 132 | columnConfig := t.createColumnConfigs() 133 | 134 | tbl.SetColumnConfigs(columnConfig) 135 | tbl.AppendHeader(headerRow) 136 | tbl.AppendRows(rows) 137 | tbl.Render() 138 | } 139 | 140 | func (t *PrintableTable) createColumnConfigs() []table.ColumnConfig { 141 | // there must be at row in order to configure column 142 | if len(t.rows) == 0 { 143 | return []table.ColumnConfig{} 144 | } 145 | 146 | colCount := len(t.rows[0]) 147 | var ( 148 | widestColIndicies []int 149 | terminalWidth = 1000 150 | // total amount padding space that a row will take up 151 | totalPaddingSpace = (colCount - 1) * minSpace 152 | remainingSpace = max(0, terminalWidth-totalPaddingSpace) 153 | // the estimated max column width by dividing the remaining space evenly across the columns 154 | maxColWidth = remainingSpace / colCount 155 | ) 156 | columnConfig := make([]table.ColumnConfig, colCount) 157 | 158 | for colIndex := range columnConfig { 159 | columnConfig[colIndex] = table.ColumnConfig{ 160 | AlignHeader: text.AlignLeft, 161 | Align: text.AlignLeft, 162 | WidthMax: maxColWidth, 163 | Number: colIndex + 1, 164 | } 165 | 166 | // assuming the table has headers: store columns with wide content where the max width may need to be adjusted 167 | // using the remaining space 168 | if t.maxSizes[colIndex] > maxColWidth && (colIndex < len(t.headers) && isWideColumn(t.headers[colIndex])) { 169 | widestColIndicies = append(widestColIndicies, colIndex) 170 | } else if t.maxSizes[colIndex] < maxColWidth { 171 | // use the max column width instead of the estimated max column width 172 | // if it is shorter 173 | columnConfig[colIndex].WidthMax = t.maxSizes[colIndex] 174 | remainingSpace -= t.maxSizes[colIndex] 175 | } else { 176 | remainingSpace -= maxColWidth 177 | } 178 | } 179 | 180 | // if only one wide column use the remaining space as the max column width 181 | if len(widestColIndicies) == 1 { 182 | widestColIndx := widestColIndicies[0] 183 | columnConfig[widestColIndx].WidthMax = remainingSpace 184 | } 185 | 186 | // if more than one wide column, spread the remaining space between the columns 187 | if len(widestColIndicies) > 1 { 188 | remainingSpace /= len(widestColIndicies) 189 | for _, columnCfgIdx := range widestColIndicies { 190 | columnConfig[columnCfgIdx].WidthMax = remainingSpace 191 | } 192 | 193 | origRemainingSpace := remainingSpace 194 | moreRemainingSpace := origRemainingSpace % len(widestColIndicies) 195 | if moreRemainingSpace != 0 { 196 | columnConfig[0].WidthMax += moreRemainingSpace 197 | } 198 | } 199 | 200 | return columnConfig 201 | } 202 | 203 | func (t *PrintableTable) createPrettyRowsAndHeaders() (headerRow table.Row, rows []table.Row) { 204 | for _, header := range t.headers { 205 | headerRow = append(headerRow, header) 206 | } 207 | 208 | for i := range t.rows { 209 | var row, emptyRow table.Row 210 | for j, cell := range t.rows[i] { 211 | if j == 0 { 212 | cell = TableContentHeaderColor(cell) 213 | } 214 | row = append(row, cell) 215 | emptyRow = append(emptyRow, "") 216 | } 217 | if i == 0 && len(t.headers) == 0 { 218 | rows = append(rows, emptyRow) 219 | } 220 | rows = append(rows, row) 221 | } 222 | 223 | return 224 | } 225 | 226 | func (t *PrintableTable) calculateMaxSize(row []string) { 227 | for index, value := range row { 228 | cellLength := runewidth.StringWidth(Decolorize(value)) 229 | if t.maxSizes[index] < cellLength { 230 | t.maxSizes[index] = cellLength 231 | } 232 | } 233 | } 234 | 235 | // Prints out a nicely/human formatted Json string instead of a table structure 236 | func (t *PrintableTable) PrintJson() { 237 | total_col := len(t.headers) - 1 238 | total_row := len(t.rows) - 1 239 | fmt.Fprintln(t.writer, "[") 240 | // Iterate through the rows 241 | for i, row := range t.rows { 242 | fmt.Fprintln(t.writer, "\t{") 243 | // Iterate through the columns in a specific row 244 | for x, point := range row { 245 | cur_col := "" 246 | // Some rows might have more columns than headers 247 | // or empty headers 248 | if x > total_col || t.headers[x] == "" { 249 | cur_col = fmt.Sprintf("column_%d", (x + 1)) 250 | } else { 251 | cur_col = t.headers[x] 252 | } 253 | entry := fmt.Sprintf("\t\t\"%s\": \"%s\"", cur_col, point) 254 | // emit a "," unless were at the last element 255 | if x != (len(row) - 1) { 256 | fmt.Fprintln(t.writer, fmt.Sprintf("%s,", entry)) 257 | } else { 258 | fmt.Fprintln(t.writer, fmt.Sprintf("%s", entry)) 259 | } 260 | } 261 | 262 | if i != total_row { 263 | fmt.Fprintln(t.writer, "\t},") 264 | } else { 265 | fmt.Fprintln(t.writer, "\t}") 266 | } 267 | } 268 | fmt.Fprintln(t.writer, "]") 269 | // mimic behavior of Print() 270 | t.rows = [][]string{} 271 | } 272 | 273 | func (t *PrintableTable) PrintCsv() error { 274 | csvwriter := csv.NewWriter(t.writer) 275 | err := csvwriter.Write(t.headers) 276 | if err != nil { 277 | return fmt.Errorf(T("Failed, header could not convert to csv format"), err.Error()) 278 | } 279 | err = csvwriter.WriteAll(t.rows) 280 | if err != nil { 281 | return fmt.Errorf(T("Failed, rows could not convert to csv format"), err.Error()) 282 | } 283 | return nil 284 | } 285 | -------------------------------------------------------------------------------- /bluemix/terminal/table_test.go: -------------------------------------------------------------------------------- 1 | package terminal_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 12 | ) 13 | 14 | // Happy path testing 15 | func TestPrintTableSimple(t *testing.T) { 16 | buf := bytes.Buffer{} 17 | testTable := NewTable(&buf, []string{"test1", "test2"}) 18 | testTable.Add("row1", "row2") 19 | testTable.Print() 20 | assert.Contains(t, buf.String(), "test2") 21 | assert.Contains(t, buf.String(), "row1") 22 | assert.Equal(t, "test1 test2\nrow1 row2\n", buf.String()) 23 | } 24 | 25 | func TestPrintTableJson(t *testing.T) { 26 | buf := bytes.Buffer{} 27 | testTable := NewTable(&buf, []string{"test1", "test2"}) 28 | testTable.Add("row1-col1", "row1-col2") 29 | testTable.Add("row2-col1", "row2-col2") 30 | testTable.PrintJson() 31 | assert.Contains(t, buf.String(), "\"test1\": \"row1-col1\"") 32 | assert.Contains(t, buf.String(), "\"test2\": \"row2-col2\"") 33 | } 34 | 35 | // Blank headers 36 | func TestEmptyHeaderTable(t *testing.T) { 37 | buf := bytes.Buffer{} 38 | testTable := NewTable(&buf, []string{"", ""}) 39 | testTable.Add("row1", "row2") 40 | testTable.Print() 41 | assert.Contains(t, buf.String(), "row1") 42 | assert.Equal(t, "\nrow1 row2\n", buf.String()) 43 | } 44 | 45 | func TestEmptyHeaderTableJson(t *testing.T) { 46 | buf := bytes.Buffer{} 47 | testTable := NewTable(&buf, []string{"", ""}) 48 | testTable.Add("row1", "row2") 49 | testTable.PrintJson() 50 | assert.Contains(t, buf.String(), "\"column_2\": \"row2\"") 51 | assert.Contains(t, buf.String(), "\"column_1\": \"row1\"") 52 | } 53 | 54 | // Empty Headers / More rows than headers 55 | func TestZeroHeadersTable(t *testing.T) { 56 | buf := bytes.Buffer{} 57 | testTable := NewTable(&buf, []string{}) 58 | testTable.Add("row1", "row2") 59 | testTable.Print() 60 | assert.Contains(t, buf.String(), "row1") 61 | assert.Equal(t, "\nrow1 row2\n", buf.String()) 62 | } 63 | 64 | func TestZeroHeadersTableJson(t *testing.T) { 65 | buf := bytes.Buffer{} 66 | testTable := NewTable(&buf, []string{}) 67 | testTable.Add("row1", "row2") 68 | testTable.PrintJson() 69 | assert.Contains(t, buf.String(), "row1") 70 | assert.Contains(t, buf.String(), "\"column_2\": \"row2\"") 71 | assert.Contains(t, buf.String(), "\"column_1\": \"row1\"") 72 | } 73 | 74 | // Empty rows / More headers than rows 75 | 76 | func TestNotEnoughRowEntires(t *testing.T) { 77 | buf := bytes.Buffer{} 78 | testTable := NewTable(&buf, []string{"col1", "col2"}) 79 | testTable.Add("row1") 80 | testTable.Add("", "row2") 81 | testTable.Print() 82 | assert.Contains(t, buf.String(), "row1") 83 | assert.Equal(t, "col1 col2\nrow1\n row2\n", buf.String()) 84 | } 85 | 86 | func TestMoreColThanTerminalWidth(t *testing.T) { 87 | os.Setenv("TEST_TERMINAL_WIDTH", "1") 88 | buf := bytes.Buffer{} 89 | testTable := NewTable(&buf, []string{"col1"}) 90 | testTable.Add("row1", "row2") 91 | testTable.Print() 92 | assert.Contains(t, buf.String(), "row1") 93 | assert.Equal(t, "col1\nrow1 row2\n", buf.String()) 94 | os.Unsetenv("TEST_TERMINAL_WIDTH") 95 | } 96 | 97 | func TestWideHeaderNames(t *testing.T) { 98 | t.Skip("Skip until terminal width issue is fixed") 99 | buf := bytes.Buffer{} 100 | testTable := NewTable(&buf, []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt u", "NAME"}) 101 | testTable.Add("col1", "col2") 102 | testTable.Print() 103 | assert.Contains(t, buf.String(), "Lorem ipsum dolor sit amet, consectetu") 104 | assert.Equal(t, "Lorem ipsum dolor sit amet, consectetu NAME\nr adipiscing elit, sed do eiusmod temp\nor incididunt u\ncol1 col2\n", buf.String()) 105 | } 106 | 107 | func TestWidestColumn(t *testing.T) { 108 | buf := bytes.Buffer{} 109 | id := "ABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332" 110 | testTable := NewTable(&buf, []string{"ID", "Name"}) 111 | testTable.Add(id, "row2") 112 | testTable.Print() 113 | assert.Contains(t, buf.String(), id) 114 | assert.Equal(t, buf.String(), "ID Name\nABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332 row2\n") 115 | } 116 | 117 | func TestMultiWideColumns(t *testing.T) { 118 | t.Skip("Skip until terminal width issue is fixed") 119 | buf := bytes.Buffer{} 120 | id := "ABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332" 121 | desc := "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut" 122 | testTable := NewTable(&buf, []string{"ID", "Description", "Name"}) 123 | testTable.Add(id, desc, "col3") 124 | testTable.Print() 125 | assert.Contains(t, buf.String(), "ABCDEFG-9b8babbd-f2ed-4371-b817-a839") 126 | assert.Contains(t, buf.String(), "e4130332") 127 | assert.Equal(t, buf.String(), "ID Description Name\nABCDEFG-9b8babbd-f2ed-4371-b817-a839 Lorem ipsum dolor sit amet, consect col3\ne4130332 etur adipiscing elit, sed do eiusmo\n d tempor incididunt ut\n") 128 | } 129 | 130 | func TestNotEnoughRowEntiresJson(t *testing.T) { 131 | buf := bytes.Buffer{} 132 | testTable := NewTable(&buf, []string{}) 133 | testTable.Add("row1") 134 | testTable.Add("", "row2") 135 | testTable.PrintJson() 136 | assert.Contains(t, buf.String(), "row1") 137 | assert.Contains(t, buf.String(), "\"column_2\": \"row2\"") 138 | assert.Contains(t, buf.String(), "\"column_1\": \"row1\"") 139 | assert.Contains(t, buf.String(), "\"column_1\": \"\"") 140 | } 141 | 142 | func TestPrintCsvSimple(t *testing.T) { 143 | buf := bytes.Buffer{} 144 | testTable := NewTable(&buf, []string{"col1", "col2"}) 145 | testTable.Add("row1-col1", "row1-col2") 146 | testTable.Add("row2-col1", "row2-col2") 147 | err := testTable.PrintCsv() 148 | assert.Equal(t, err, nil) 149 | assert.Contains(t, buf.String(), "col1,col2") 150 | assert.Contains(t, buf.String(), "row1-col1,row1-col2") 151 | assert.Contains(t, buf.String(), "row2-col1,row2-col2") 152 | } 153 | 154 | func TestNotEnoughColPrintCsv(t *testing.T) { 155 | buf := bytes.Buffer{} 156 | testTable := NewTable(&buf, []string{"", "col2"}) 157 | testTable.Add("row1-col1", "row1-col2") 158 | testTable.Add("row2-col1", "row2-col2") 159 | err := testTable.PrintCsv() 160 | assert.Equal(t, err, nil) 161 | assert.Contains(t, buf.String(), ",col2") 162 | assert.Contains(t, buf.String(), "row1-col1,row1-col2") 163 | assert.Contains(t, buf.String(), "row2-col1,row2-col2") 164 | } 165 | 166 | func TestNotEnoughRowPrintCsv(t *testing.T) { 167 | buf := bytes.Buffer{} 168 | testTable := NewTable(&buf, []string{"col1", "col2"}) 169 | testTable.Add("row1-col1", "row1-col2") 170 | testTable.Add("row2-col1", "") 171 | err := testTable.PrintCsv() 172 | assert.Equal(t, err, nil) 173 | assert.Contains(t, buf.String(), "col1,col2") 174 | assert.Contains(t, buf.String(), "row1-col1,row1-col2") 175 | assert.Contains(t, buf.String(), "row2-col1,") 176 | } 177 | 178 | func TestEmptyTable(t *testing.T) { 179 | buf := bytes.Buffer{} 180 | testTable := NewTable(&buf, []string{}) 181 | err := testTable.PrintCsv() 182 | assert.Equal(t, err, nil) 183 | assert.Equal(t, len(strings.TrimSpace(buf.String())), 0) 184 | } 185 | -------------------------------------------------------------------------------- /bluemix/terminal/ui.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" 9 | ) 10 | 11 | // UI provides utilities to handle input and output streams 12 | type UI interface { 13 | // Deprecated: this method could be removed in the future, 14 | // Use Info() if the message is printed to StdErr and it will be suppressed in quiet mode 15 | // Or use Verbose() if the message is printed to StdOut and it will be suppressed in quiet mode 16 | // Say prints the formated message to StdOut, the message will NOT be suppressed in quiet mode 17 | Say(format string, args ...interface{}) 18 | 19 | // Verbose prints message to StdOut, the message will be suppressed in quiet mode 20 | Verbose(format string, args ...interface{}) 21 | 22 | // Info prints message to StdErr, the message will be suppressed in quiet mode 23 | Info(format string, args ...interface{}) 24 | 25 | // Warn prints the formated warning message to StdErr, the message will be suppressed in quiet mode 26 | Warn(format string, args ...interface{}) 27 | 28 | // Failed prints the formated failure message to StdErr, word `FAILED` will be suppressed in quiet mode. 29 | // But the message itself will NOT be suppressed. 30 | Failed(format string, args ...interface{}) 31 | 32 | // Print will send the message to StdOut, the message will NOT be suppressed in quiet mode 33 | Print(format string, args ...interface{}) 34 | 35 | // OK prints 'OK' to StdOut, the message will be suppressed in quiet mode 36 | Ok() 37 | 38 | // Prompt creates a single Prompt 39 | Prompt(message string, options *PromptOptions) *Prompt 40 | 41 | // ChoicePrompt creates a choice prompt 42 | ChoicesPrompt(message string, choices []string, options *PromptOptions) *Prompt 43 | 44 | // Ask asks for text answer 45 | // Deprecated: use Prompt instead 46 | Ask(format string, args ...interface{}) (answer string, err error) 47 | 48 | // AskForPassword asks for password 49 | // Deprecated: use Prompt instead 50 | AskForPassword(format string, args ...interface{}) (answer string, err error) 51 | 52 | // Confirm asks for user confirmation 53 | // Deprecated: use Prompt instead 54 | Confirm(format string, args ...interface{}) (bool, error) 55 | 56 | // ConfirmWithDefault asks for user confirmation. If user skipped, return 57 | // defaultBool Deprecated: use Prompt instead 58 | ConfirmWithDefault(defaultBool bool, format string, args ...interface{}) (bool, error) 59 | 60 | // SelectOne asks to select one from choices. It returns the selected index. 61 | // Deprecated: use ChoicesPrompt instead 62 | SelectOne(choices []string, format string, args ...interface{}) (int, error) 63 | 64 | // Table creates a table with the given headers 65 | Table(headers []string) Table 66 | 67 | // Writer returns the output writer of the terminal UI 68 | Writer() io.Writer 69 | 70 | // SetWriter sets the writer of the terminal UI 71 | SetWriter(buf io.Writer) 72 | 73 | // ErrWriter returns the error writer of the terminal UI 74 | ErrWriter() io.Writer 75 | 76 | // SetErrWriter sets the error writer of the terminal UI 77 | SetErrWriter(buf io.Writer) 78 | 79 | // Enable or disable quiet mode. Contents passed to Verbose(), Warn(), OK() will be ignored if under quiet mode. 80 | SetQuiet(bool) 81 | 82 | // Return whether quiet mode is enabled or not 83 | Quiet() bool 84 | } 85 | 86 | type terminalUI struct { 87 | In io.Reader 88 | Out io.Writer 89 | ErrOut io.Writer 90 | quiet bool 91 | } 92 | 93 | // NewStdUI initialize a terminal UI with os.Stdin and os.Stdout 94 | func NewStdUI() UI { 95 | return NewUI(os.Stdin, Output, ErrOutput) 96 | } 97 | 98 | // NewUI initialize a terminal UI with io.Reader and io.Writer 99 | func NewUI(in io.Reader, out io.Writer, errOut io.Writer) UI { 100 | return &terminalUI{ 101 | In: in, 102 | Out: out, 103 | ErrOut: errOut, 104 | } 105 | } 106 | 107 | func (ui *terminalUI) Say(format string, args ...interface{}) { 108 | ui.Print(format, args...) 109 | } 110 | 111 | func (ui *terminalUI) Verbose(format string, args ...interface{}) { 112 | if ui.quiet { 113 | return 114 | } 115 | ui.Print(format, args...) 116 | } 117 | 118 | func (ui *terminalUI) Info(format string, args ...interface{}) { 119 | if ui.quiet { 120 | return 121 | } 122 | ui.Error(format, args...) 123 | } 124 | 125 | func (ui *terminalUI) Warn(format string, args ...interface{}) { 126 | if ui.quiet { 127 | return 128 | } 129 | 130 | message := fmt.Sprintf(format, args...) 131 | ui.Error(WarningColor(message)) 132 | } 133 | 134 | func (ui *terminalUI) Ok() { 135 | if ui.quiet { 136 | return 137 | } 138 | 139 | ui.Say(SuccessColor(T("OK"))) 140 | } 141 | 142 | func (ui *terminalUI) Print(format string, args ...interface{}) { 143 | if args != nil { 144 | fmt.Fprintf(ui.Out, format+"\n", args...) 145 | } else { 146 | fmt.Fprint(ui.Out, format+"\n") 147 | } 148 | } 149 | 150 | func (ui *terminalUI) Error(format string, args ...interface{}) { 151 | if args != nil { 152 | fmt.Fprintf(ui.ErrOut, format+"\n", args...) 153 | } else { 154 | fmt.Fprint(ui.ErrOut, format+"\n") 155 | } 156 | } 157 | 158 | func (ui *terminalUI) Failed(format string, args ...interface{}) { 159 | ui.Info(FailureColor(T("FAILED"))) 160 | ui.Error(format, args...) 161 | ui.Info("") 162 | } 163 | 164 | func (ui *terminalUI) Prompt(message string, options *PromptOptions) *Prompt { 165 | p := NewPrompt(message, options) 166 | p.Reader = ui.In 167 | p.Writer = ui.Out 168 | return p 169 | } 170 | 171 | func (ui *terminalUI) ChoicesPrompt(message string, choices []string, options *PromptOptions) *Prompt { 172 | p := NewChoicesPrompt(message, choices, options) 173 | p.Reader = ui.In 174 | p.Writer = ui.Out 175 | return p 176 | } 177 | 178 | func (ui *terminalUI) Ask(format string, args ...interface{}) (answer string, err error) { 179 | message := fmt.Sprintf(format, args...) 180 | err = ui.Prompt(message, &PromptOptions{HideDefault: true, NoLoop: true}).Resolve(&answer) 181 | return 182 | } 183 | 184 | func (ui *terminalUI) AskForPassword(format string, args ...interface{}) (passwd string, err error) { 185 | message := fmt.Sprintf(format, args...) 186 | err = ui.Prompt(message, &PromptOptions{HideInput: true, HideDefault: true, NoLoop: true}).Resolve(&passwd) 187 | return 188 | } 189 | 190 | func (ui *terminalUI) Confirm(format string, args ...interface{}) (yn bool, err error) { 191 | message := fmt.Sprintf(format, args...) 192 | err = ui.Prompt(message, &PromptOptions{HideDefault: true, NoLoop: true}).Resolve(&yn) 193 | return 194 | } 195 | 196 | func (ui *terminalUI) ConfirmWithDefault(defaultBool bool, format string, args ...interface{}) (yn bool, err error) { 197 | yn = defaultBool 198 | message := fmt.Sprintf(format, args...) 199 | err = ui.Prompt(message, &PromptOptions{HideDefault: true, NoLoop: true}).Resolve(&yn) 200 | return 201 | } 202 | 203 | func (ui *terminalUI) SelectOne(choices []string, format string, args ...interface{}) (int, error) { 204 | var selected string 205 | message := fmt.Sprintf(format, args...) 206 | 207 | err := ui.ChoicesPrompt(message, choices, &PromptOptions{HideDefault: true}).Resolve(&selected) 208 | if err != nil { 209 | return -1, err 210 | } 211 | 212 | for i, c := range choices { 213 | if selected == c { 214 | return i, nil 215 | } 216 | } 217 | 218 | return -1, nil 219 | } 220 | 221 | func (ui *terminalUI) Table(headers []string) Table { 222 | return NewTable(ui.Out, headers) 223 | } 224 | 225 | func (ui *terminalUI) Writer() io.Writer { 226 | return ui.Out 227 | } 228 | 229 | func (ui *terminalUI) ErrWriter() io.Writer { 230 | return ui.ErrOut 231 | } 232 | 233 | func (ui *terminalUI) SetWriter(buf io.Writer) { 234 | ui.Out = buf 235 | } 236 | 237 | func (ui *terminalUI) SetErrWriter(buf io.Writer) { 238 | ui.ErrOut = buf 239 | } 240 | 241 | func (ui *terminalUI) SetQuiet(quiet bool) { 242 | ui.quiet = quiet 243 | } 244 | 245 | func (ui *terminalUI) Quiet() bool { 246 | return ui.quiet 247 | } 248 | -------------------------------------------------------------------------------- /bluemix/trace/trace.go: -------------------------------------------------------------------------------- 1 | package trace 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 13 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" 14 | ) 15 | 16 | // Printer will write logs to specific output. 17 | type Printer interface { 18 | // Print formats using the default formats for its operands and writes to specific output. 19 | Print(v ...interface{}) 20 | // Printf formats according to a format specifier and writes to specific output. 21 | Printf(format string, v ...interface{}) 22 | // Println formats using the default formats for its operands and writes to specific output. 23 | Println(v ...interface{}) 24 | } 25 | 26 | // Closer will close the specific output 27 | type Closer interface { 28 | Close() error 29 | } 30 | 31 | // PrinterCloser is the Printer which can be closed 32 | type PrinterCloser interface { 33 | Printer 34 | Closer 35 | } 36 | 37 | // NullLogger will drop all inputs 38 | type NullLogger struct{} 39 | 40 | func (l *NullLogger) Print(v ...interface{}) {} 41 | func (l *NullLogger) Printf(format string, v ...interface{}) {} 42 | func (l *NullLogger) Println(v ...interface{}) {} 43 | 44 | type loggerImpl struct { 45 | *log.Logger 46 | c io.WriteCloser 47 | } 48 | 49 | func (loggerImpl *loggerImpl) Close() error { 50 | if loggerImpl.c != nil { 51 | return loggerImpl.c.Close() 52 | } 53 | return nil 54 | } 55 | 56 | func newLoggerImpl(out io.Writer, prefix string, flag int) *loggerImpl { 57 | l := log.New(out, prefix, flag) 58 | c, _ := out.(io.WriteCloser) 59 | return &loggerImpl{ 60 | Logger: l, 61 | c: c, 62 | } 63 | } 64 | 65 | // Logger is the default logger 66 | var Logger Printer = NewLogger("") 67 | 68 | // NewLogger returns a printer for the given trace setting. 69 | func NewLogger(bluemixTrace string) Printer { 70 | switch strings.ToLower(bluemixTrace) { 71 | case "", "false": 72 | return new(NullLogger) 73 | case "true": 74 | return NewStdLogger() 75 | default: 76 | return NewFileLogger(bluemixTrace) 77 | } 78 | } 79 | 80 | // NewStdLogger creates a a printer that writes to StdOut. 81 | func NewStdLogger() PrinterCloser { 82 | return newLoggerImpl(terminal.ErrOutput, "", 0) 83 | } 84 | 85 | // NewFileLogger creates a printer that writes to the given file path. 86 | func NewFileLogger(path string) PrinterCloser { 87 | file, err := os.OpenFile(filepath.Clean(path), os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600) 88 | if err != nil { 89 | logger := NewStdLogger() 90 | logger.Printf(T("An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", map[string]interface{}{"Path": path, "Error": err.Error()})) 91 | return logger 92 | } 93 | return newLoggerImpl(file, "", 0) 94 | } 95 | 96 | var privateDataPlaceholder = "[PRIVATE DATA HIDDEN]" 97 | 98 | // Sanitize returns a clean string with sensitive user data in the input 99 | // replaced by PRIVATE_DATA_PLACEHOLDER. 100 | func Sanitize(input string) string { 101 | re := regexp.MustCompile(`(?m)^(Authorization|X-Auth\S*): .*`) 102 | sanitized := re.ReplaceAllString(input, "$1: "+privateDataPlaceholder) 103 | 104 | re = regexp.MustCompile(`(?i)(password|token|apikey|passcode)=[^&]*(&|$)`) 105 | sanitized = re.ReplaceAllString(sanitized, "$1="+privateDataPlaceholder+"$2") 106 | 107 | re = regexp.MustCompile(`(?i)"([^"]*(password|token|apikey)[^"_]*)":\s*"[^\,]*"`) 108 | sanitized = re.ReplaceAllString(sanitized, fmt.Sprintf(`"$1":"%s"`, privateDataPlaceholder)) 109 | 110 | return sanitized 111 | } 112 | -------------------------------------------------------------------------------- /bluemix/trace/trace_test.go: -------------------------------------------------------------------------------- 1 | package trace_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "testing" 9 | 10 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 11 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/trace" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | type sanitizeTest struct { 16 | input, expected string 17 | } 18 | 19 | func TestSanitize(t *testing.T) { 20 | tests := []sanitizeTest{ 21 | { 22 | input: "Authorization: Bearer eyJraWQiOiIyMDE3MTAzMC0wMDowMDowMCIsImFsZyI6IlJTMjU2In0.rewyrifvoi4243efjlwejf1pZC0yNzAwMDc2UURLIiwicmVhbG1pZCI6IklCTWlkIiwiaWRlbnRpZmllciI6IjI3MDAwNzZdgyidkgfdkgkfdhgkfdhjldfgklfdkiOiJ3d3dlaXdlaUBjbi5pYm0uY29tIiwic3ViIjoid3d3ZWl3ZWlAY24uaWJtLmNvbSIsImlhdCI6MTUxNzUzMzYyMCwiZXhwIjoxNTE3NTM3MjIwLCJpc3MiOiJodHRwczovL2lhbS5ibHVlbWl4Lm5ldC9pZGVudGl0eSIsImdyYW50X3R5cGUiOiJwYXNzd29yZCIsInNjb3BlIjoib3BlbmlkIiwiY2xpZW50X2lkIjoiYngifQ.EoevrKa7BmxQiY7tew98turjldjgns11_jQ3E8Ay1iPcDGqofZ-YwpUPbEwpZn1lERentiv8Dd0939c3qZlXCalLAlryaX99RgyigtrekjgflwrtprewiteroWD7TIouqIaYar-Me72Uug2obW3Nd9G41NHH_5WnvlnyKrbyCl6G__Jyn8CVbo6TaiorKtQa-1_g4kOOA6tVbWiq93LklIi_-0nSUI2-wgWP4IRE4kwOge92NBzTPgeAvQ", 23 | expected: "Authorization: [PRIVATE DATA HIDDEN]", 24 | }, 25 | { 26 | input: "X-Auth-Token: the-auth-token", 27 | expected: "X-Auth-Token: [PRIVATE DATA HIDDEN]", 28 | }, 29 | { 30 | input: "grant_type=password&password=my-password", 31 | expected: "grant_type=password&password=[PRIVATE DATA HIDDEN]", 32 | }, 33 | { 34 | input: "grant_type=refresh_token&refresh_token=eyJraWQiOiIyMDE3MTAzMC0wMDowMDowMCIsImFsZyI6IlJTMjU2In0.rewyrifvoi4243efjlwejf1pZC0yNzAwMDc2UURLIiwicmVhbG1pZCI6IklCTWlkIiwiaWRlbnRpZmllciI6IjI3MDAwNzZdgyidkgfdkgkfdhgkfdhjldfgklfdkiOiJ3d3dlaXdlaUBjbi5pYm0uY29tIiwic3ViIjoid3d3ZWl3ZWlAY24uaWJtLmNvbSIsImlhdCI6MTUxNzUzMzYyMCwiZXhwIjoxNTE3NTM3MjIwLCJpc3MiOiJodHRwczovL2lhbS5ibHVlbWl4Lm5ldC9pZGVudGl0eSIsImdyYW50X3R5cGUiOiJwYXNzd29yZCIsInNjb3BlIjoib3BlbmlkIiwiY2xpZW50X2lkIjoiYngifQ.EoevrKa7BmxQiY7tew98turjldjgns11_jQ3E8Ay1iPcDGqofZ-YwpUPbEwpZn1lERentiv8Dd0939c3qZlXCalLAlryaX99RgyigtrekjgflwrtprewiteroWD7TIouqIaYar-Me72Uug2obW3Nd9G41NHH_5WnvlnyKrbyCl6G__Jyn8CVbo6TaiorKtQa-1_g4kOOA6tVbWiq93LklIi_-0nSUI2-wgWP4IRE4kwOge92NBzTPgeAvQ", 35 | expected: "grant_type=refresh_token&refresh_token=[PRIVATE DATA HIDDEN]", 36 | }, 37 | { 38 | input: "apikey=my-api-key&grant_type=urn:ibm:params:oauth:grant-type:apikey", 39 | expected: "apikey=[PRIVATE DATA HIDDEN]&grant_type=urn:ibm:params:oauth:grant-type:apikey", 40 | }, 41 | { 42 | input: "passcode=the-one-time-code", 43 | expected: "passcode=[PRIVATE DATA HIDDEN]", 44 | }, 45 | { 46 | input: "PASSCODE=the-one-time-code", 47 | expected: "PASSCODE=[PRIVATE DATA HIDDEN]", 48 | }, 49 | { 50 | input: `{"access_token":"the-access-token","refresh_token":"the-refresh-token"}`, 51 | expected: `{"access_token":"[PRIVATE DATA HIDDEN]","refresh_token":"[PRIVATE DATA HIDDEN]"}`, 52 | }, 53 | { 54 | input: `{"token_endpoint":"https://the-token-endpoint"}`, 55 | expected: `{"token_endpoint":"https://the-token-endpoint"}`, 56 | }, 57 | } 58 | 59 | for _, test := range tests { 60 | assert.Equal(t, test.expected, trace.Sanitize(test.input)) 61 | } 62 | } 63 | 64 | func TestNullLogger(t *testing.T) { 65 | logger := trace.NewLogger("") 66 | logger.Print("test") 67 | logger.Printf("test %d", 100) 68 | logger.Println("test") 69 | } 70 | 71 | func TestStdLogger(t *testing.T) { 72 | originalStderr := terminal.ErrOutput 73 | 74 | r, w, err := os.Pipe() 75 | assert.NoError(t, err) 76 | 77 | terminal.ErrOutput = w 78 | 79 | defer func() { 80 | terminal.ErrOutput = originalStderr 81 | }() 82 | 83 | logger := trace.NewLogger("true") 84 | logger.Print("test") 85 | logger.Printf("test %d", 100) 86 | logger.Println("testln") 87 | 88 | errChan := make(chan string) 89 | go func() { 90 | var buf bytes.Buffer 91 | io.Copy(&buf, r) 92 | errChan <- buf.String() 93 | }() 94 | 95 | w.Close() 96 | 97 | output := <-errChan 98 | 99 | assert.Equal(t, "test\ntest 100\ntestln\n", output) 100 | } 101 | 102 | func TestFileLogger(t *testing.T) { 103 | f, err := ioutil.TempFile("", "") 104 | assert.NoError(t, err) 105 | 106 | defer f.Close() 107 | defer os.RemoveAll(f.Name()) 108 | 109 | logger := trace.NewLogger(f.Name()) 110 | logger.Print("test") 111 | logger.Printf("test %d", 100) 112 | logger.Println("testln") 113 | 114 | buf, err := ioutil.ReadAll(f) 115 | assert.NoError(t, err) 116 | 117 | assert.Equal(t, "test\ntest 100\ntestln\n", string(buf)) 118 | } 119 | 120 | func TestPrinterCloser(t *testing.T) { 121 | f, err := ioutil.TempFile("", "") 122 | assert.NoError(t, err) 123 | 124 | defer os.RemoveAll(f.Name()) 125 | 126 | logger := trace.NewFileLogger(f.Name()) 127 | logger.Print("test") 128 | logger.Printf("test %d", 100) 129 | logger.Println("testln") 130 | 131 | buf, err := ioutil.ReadAll(f) 132 | assert.NoError(t, err) 133 | 134 | assert.Equal(t, "test\ntest 100\ntestln\n", string(buf)) 135 | 136 | assert.NoError(t, logger.Close()) 137 | assert.Error(t, logger.Close()) 138 | } 139 | -------------------------------------------------------------------------------- /bluemix/version.go: -------------------------------------------------------------------------------- 1 | package bluemix 2 | 3 | import "fmt" 4 | 5 | // Version is the SDK version 6 | var Version = VersionType{Major: 1, Minor: 7, Build: 3} 7 | 8 | // VersionType describe version info 9 | type VersionType struct { 10 | Major int // major version 11 | Minor int // minor version 12 | Build int // build number 13 | } 14 | 15 | // String will return the version in semver format string "Major.Minor.Build" 16 | func (v VersionType) String() string { 17 | if v == (VersionType{}) { 18 | return "" 19 | } 20 | return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Build) 21 | } 22 | -------------------------------------------------------------------------------- /bluemix/version_test.go: -------------------------------------------------------------------------------- 1 | package bluemix_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestVersion(t *testing.T) { 11 | v := VersionType{} 12 | assert.Equal(t, 0, v.Major) 13 | assert.Equal(t, 0, v.Minor) 14 | assert.Equal(t, 0, v.Build) 15 | assert.Empty(t, v.String()) 16 | 17 | v = VersionType{Major: 1, Minor: 2, Build: 3} 18 | assert.Equal(t, "1.2.3", v.String()) 19 | } 20 | -------------------------------------------------------------------------------- /common/downloader/file_downloader.go: -------------------------------------------------------------------------------- 1 | // Package downloader provides a simple file downlaoder 2 | package downloader 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | // ProxyReader is an interface to proxy read bytes 16 | type ProxyReader interface { 17 | Proxy(size int64, reader io.Reader) io.Reader 18 | Finish() 19 | } 20 | 21 | // FileDownloader is a file downloader 22 | type FileDownloader struct { 23 | SaveDir string // path of the directory to save the downloaded file 24 | DefaultHeader http.Header // Default header to applied to the download request 25 | Client *http.Client // HTTP client to use, default is http DefaultClient 26 | ProxyReader ProxyReader 27 | } 28 | 29 | // New creates a file downloader 30 | func New(saveDir string) *FileDownloader { 31 | return &FileDownloader{ 32 | SaveDir: saveDir, 33 | Client: http.DefaultClient, 34 | DefaultHeader: make(http.Header), 35 | } 36 | } 37 | 38 | // Download downloads a file from a URL and returns the path and size of the 39 | // downloaded file 40 | func (d *FileDownloader) Download(url string) (dest string, size int64, err error) { 41 | return d.DownloadTo(url, "") 42 | } 43 | 44 | // DownloadTo downloads a file from a URL to the file with the specified name in 45 | // the download directory 46 | func (d *FileDownloader) DownloadTo(url string, outputName string) (dest string, size int64, err error) { 47 | req, err := d.createRequest(url) 48 | if err != nil { 49 | return "", 0, fmt.Errorf("download request error: %v", err) 50 | } 51 | 52 | client := d.Client 53 | if client == nil { 54 | client = http.DefaultClient 55 | } 56 | 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | return "", 0, err 60 | } 61 | 62 | defer func() error { 63 | if err := resp.Body.Close(); err != nil { 64 | return err 65 | } 66 | return nil 67 | }() 68 | 69 | if resp.StatusCode != 200 { 70 | return "", 0, fmt.Errorf("Unexpected response code %d", resp.StatusCode) 71 | } 72 | 73 | if outputName == "" { 74 | outputName = d.determinOutputName(resp) 75 | } 76 | dest = filepath.Join(d.SaveDir, outputName) 77 | 78 | f, err := os.OpenFile(filepath.Clean(dest), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) 79 | if err != nil { 80 | return dest, 0, err 81 | } 82 | 83 | defer func() { 84 | if err := f.Close(); err != nil { 85 | fmt.Printf("Error closing file: %s\n", err) 86 | } 87 | }() 88 | 89 | var r io.Reader = resp.Body 90 | if d.ProxyReader != nil { 91 | defer d.ProxyReader.Finish() 92 | r = d.ProxyReader.Proxy(resp.ContentLength, r) 93 | } 94 | 95 | size, err = io.Copy(f, r) 96 | if err != nil { 97 | return dest, size, err 98 | } 99 | 100 | return dest, size, nil 101 | } 102 | 103 | // RemoveDir removes the download directory 104 | func (d *FileDownloader) RemoveDir() error { 105 | return os.RemoveAll(d.SaveDir) 106 | } 107 | 108 | func (d *FileDownloader) createRequest(url string) (*http.Request, error) { 109 | r, err := http.NewRequest(http.MethodGet, url, nil) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | if d.DefaultHeader != nil { 115 | r.Header = d.DefaultHeader 116 | } 117 | 118 | if r.Header.Get("User-Agent") == "" { 119 | r.Header.Set("User-Agent", "bluemix-cli") 120 | } 121 | 122 | return r, nil 123 | } 124 | 125 | func (d *FileDownloader) determinOutputName(resp *http.Response) string { 126 | n := getFileNameFromHeader(resp.Header.Get("Content-Disposition")) 127 | 128 | if n == "" { 129 | n = getFileNameFromUrl(resp.Request.URL) 130 | } 131 | 132 | if n == "" { 133 | n = "index.html" 134 | } 135 | 136 | return n 137 | } 138 | 139 | func getFileNameFromUrl(url *url.URL) string { 140 | path := path.Clean(url.Path) 141 | if path == "." { 142 | return "" 143 | } 144 | 145 | fields := strings.Split(path, "/") 146 | if len(fields) == 0 { 147 | return "" 148 | } 149 | 150 | return fields[len(fields)-1] 151 | } 152 | 153 | func getFileNameFromHeader(header string) string { 154 | if header == "" { 155 | return "" 156 | } 157 | 158 | for _, field := range strings.Split(header, ";") { 159 | field = strings.TrimSpace(field) 160 | 161 | if strings.HasPrefix(field, "filename=") { 162 | name := strings.TrimLeft(field, "filename=") 163 | return strings.Trim(name, `"`) 164 | } 165 | } 166 | 167 | return "" 168 | } 169 | -------------------------------------------------------------------------------- /common/downloader/file_downloader_test.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/suite" 14 | 15 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/file_helpers" 16 | ) 17 | 18 | type DownloadTestSuite struct { 19 | suite.Suite 20 | downloader *FileDownloader 21 | } 22 | 23 | func TestDownloadTestSuite(t *testing.T) { 24 | suite.Run(t, new(DownloadTestSuite)) 25 | } 26 | 27 | func (suite *DownloadTestSuite) SetupTest() { 28 | tmpDir, err := ioutil.TempDir("", "testfiledownload") 29 | suite.NoError(err) 30 | suite.downloader = New(tmpDir) 31 | } 32 | 33 | func (suite *DownloadTestSuite) TearDownTest() { 34 | if suite.downloader.SaveDir != "" { 35 | os.RemoveAll(suite.downloader.SaveDir) 36 | } 37 | } 38 | 39 | func (suite *DownloadTestSuite) TestDownload_GetFileNameFromHeader() { 40 | assert := assert.New(suite.T()) 41 | 42 | resp := "this is the file content" 43 | fileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | w.Header().Set("Content-Disposition", `attachment; filename="test.txt"`) 45 | fmt.Fprint(w, resp) 46 | })) 47 | 48 | dest, size, err := suite.downloader.Download(fileServer.URL) 49 | 50 | assert.NoError(err) 51 | assert.Equal(dest, filepath.Join(suite.downloader.SaveDir, "test.txt")) 52 | assert.True(file_helpers.FileExists(dest)) 53 | assert.Equal(size, int64(len(resp))) 54 | } 55 | 56 | func (suite *DownloadTestSuite) TestDownload_GetFileNameFromURL() { 57 | assert := assert.New(suite.T()) 58 | 59 | fileServer := CreateServerReturnContent("123") 60 | defer fileServer.Close() 61 | 62 | dest, _, err := suite.downloader.Download(fileServer.URL + "/test.txt") 63 | 64 | assert.NoError(err) 65 | assert.Equal(dest, filepath.Join(suite.downloader.SaveDir, "test.txt")) 66 | assert.True(file_helpers.FileExists(dest)) 67 | } 68 | 69 | func (suite *DownloadTestSuite) TestDownload_SaveAsIndexHTML() { 70 | assert := assert.New(suite.T()) 71 | 72 | fileServer := CreateServerReturnContent("123") 73 | defer fileServer.Close() 74 | 75 | dest, _, err := suite.downloader.Download(fileServer.URL) 76 | 77 | assert.NoError(err) 78 | assert.Equal(dest, filepath.Join(suite.downloader.SaveDir, "index.html")) 79 | assert.True(file_helpers.FileExists(dest)) 80 | } 81 | 82 | func (suite *DownloadTestSuite) TestDownloadTo() { 83 | assert := assert.New(suite.T()) 84 | 85 | fileServer := CreateServerReturnContent("123") 86 | defer fileServer.Close() 87 | 88 | dest, _, err := suite.downloader.DownloadTo(fileServer.URL, "out") 89 | 90 | assert.NoError(err) 91 | assert.Equal(dest, filepath.Join(suite.downloader.SaveDir, "out")) 92 | assert.True(file_helpers.FileExists(dest)) 93 | } 94 | 95 | func (suite *DownloadTestSuite) TestDownloadSaveDirNotSet() { 96 | assert := assert.New(suite.T()) 97 | 98 | fileServer := CreateServerReturnContent("123") 99 | defer fileServer.Close() 100 | 101 | suite.downloader = new(FileDownloader) 102 | dest, _, err := suite.downloader.Download(fileServer.URL + "/test.txt") 103 | defer os.Remove(dest) 104 | 105 | assert.NoError(err) 106 | assert.True(file_helpers.FileExists(dest)) 107 | assert.Equal(filepath.Dir(dest), ".") 108 | } 109 | 110 | func (suite *DownloadTestSuite) TestDownloadRequestHeader() { 111 | assert := assert.New(suite.T()) 112 | 113 | fileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 114 | assert.Equal(r.Header.Get("Accept-Language"), "en_US") 115 | })) 116 | defer fileServer.Close() 117 | 118 | h := http.Header{} 119 | h.Add("Accept-Language", "en_US") 120 | suite.downloader.DefaultHeader = h 121 | suite.downloader.Download(fileServer.URL + "/test.txt") 122 | } 123 | 124 | func CreateServerReturnContent(content string) *httptest.Server { 125 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 126 | fmt.Fprint(w, "this is the file content") 127 | })) 128 | } 129 | -------------------------------------------------------------------------------- /common/downloader/progress_bar.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "io" 5 | 6 | pb "gopkg.in/cheggaaa/pb.v1" 7 | ) 8 | 9 | // ProgressBar is an implementation of ProxyReader that displays a process bar 10 | // during file downloading. 11 | type ProgressBar struct { 12 | bar *pb.ProgressBar 13 | out io.Writer 14 | } 15 | 16 | func NewProgressBar(out io.Writer) *ProgressBar { 17 | return &ProgressBar{out: out} 18 | } 19 | 20 | func (p *ProgressBar) Proxy(totalSize int64, reader io.Reader) io.Reader { 21 | p.bar = pb.New(int(totalSize)).SetUnits(pb.U_BYTES) 22 | p.bar.Output = p.out 23 | p.bar.Start() 24 | 25 | return p.bar.NewProxyReader(reader) 26 | } 27 | 28 | func (p *ProgressBar) Finish() { 29 | p.bar.Finish() 30 | } 31 | -------------------------------------------------------------------------------- /common/file_helpers/file.go: -------------------------------------------------------------------------------- 1 | // Package file_helpers provides file operation helpers. 2 | package file_helpers 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | // FileExists checks if the file exist or not 13 | func FileExists(path string) bool { 14 | _, err := os.Stat(path) 15 | if err == nil { 16 | return true 17 | } 18 | if os.IsNotExist(err) { 19 | return false 20 | } 21 | return false 22 | } 23 | 24 | // RemoveFile removes the file. 25 | // If the file does not exist, it do nothing and return nil. 26 | func RemoveFile(path string) error { 27 | if FileExists(path) { 28 | return os.Remove(path) 29 | } 30 | return nil 31 | } 32 | 33 | // CopyFile copies file contents of src to dest. Both of stc and dest must be a path name. 34 | func CopyFile(src string, dest string) (err error) { 35 | srcFile, err := os.Open(filepath.Clean(src)) 36 | if err != nil { 37 | return 38 | } 39 | 40 | defer func() { 41 | if err := srcFile.Close(); err != nil { 42 | fmt.Printf("Error closing file: %s\n", err) 43 | } 44 | }() 45 | 46 | srcStat, err := srcFile.Stat() 47 | if err != nil { 48 | return 49 | } 50 | 51 | if !srcStat.Mode().IsRegular() { 52 | return fmt.Errorf("%s is not a regular file.", src) 53 | } 54 | 55 | destFile, err := os.Create(filepath.Clean(dest)) 56 | if err != nil { 57 | return 58 | } 59 | 60 | defer func() { 61 | if err := destFile.Close(); err != nil { 62 | fmt.Printf("Error closing file: %s\n", err) 63 | } 64 | }() 65 | 66 | err = os.Chmod(dest, srcStat.Mode()) 67 | if err != nil { 68 | return 69 | } 70 | 71 | _, err = io.Copy(destFile, srcFile) 72 | return 73 | } 74 | 75 | // CopyDir copies src directory recursively to dest. 76 | // Both of src and dest must be a path name. 77 | // It returns error if dest already exist. 78 | func CopyDir(src string, dest string) (err error) { 79 | srcStat, err := os.Stat(src) 80 | if err != nil { 81 | return 82 | } 83 | 84 | if !srcStat.Mode().IsDir() { 85 | return fmt.Errorf("%s is not a directory.", src) 86 | } 87 | 88 | err = os.MkdirAll(dest, srcStat.Mode()) 89 | if err != nil { 90 | return 91 | } 92 | 93 | entries, err := ioutil.ReadDir(src) 94 | if err != nil { 95 | return 96 | } 97 | 98 | for _, entry := range entries { 99 | srcPath := filepath.Join(src, entry.Name()) 100 | destPath := filepath.Join(dest, entry.Name()) 101 | 102 | if entry.Mode().IsDir() { 103 | err = CopyDir(srcPath, destPath) 104 | } else { 105 | err = CopyFile(srcPath, destPath) 106 | } 107 | if err != nil { 108 | return 109 | } 110 | } 111 | 112 | return 113 | } 114 | -------------------------------------------------------------------------------- /common/file_helpers/gzip.go: -------------------------------------------------------------------------------- 1 | package file_helpers 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | // ExtractTgz extracts src archive to the dest directory. Both src and dest must be a path name. 13 | func ExtractTgz(src string, dest string) error { 14 | fd, err := os.Open(filepath.Clean(src)) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | defer func() { 20 | if err := fd.Close(); err != nil { 21 | fmt.Printf("Error closing file: %s\n", err) 22 | } 23 | }() 24 | 25 | gReader, err := gzip.NewReader(fd) 26 | if err != nil { 27 | return err 28 | } 29 | defer gReader.Close() 30 | 31 | tarReader := tar.NewReader(gReader) 32 | 33 | for { 34 | hdr, err := tarReader.Next() 35 | if err == io.EOF { 36 | break 37 | } 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if hdr.Name == "." { 43 | continue 44 | } 45 | 46 | err = extractFileInArchive(tarReader, hdr, dest) 47 | if err != nil { 48 | return err 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func extractFileInArchive(r io.Reader, hdr *tar.Header, dest string) error { 56 | fi := hdr.FileInfo() 57 | path := filepath.Join(filepath.Clean(dest), filepath.Clean(hdr.Name)) 58 | 59 | if fi.IsDir() { 60 | return os.MkdirAll(path, fi.Mode()) 61 | } else { 62 | err := os.MkdirAll(filepath.Dir(path), 0700) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | f, err := os.Create(filepath.Clean(path)) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | defer func() { 73 | if err := f.Close(); err != nil { 74 | fmt.Printf("Error closing file: %s\n", err) 75 | } 76 | }() 77 | 78 | _, err = io.Copy(f, r) 79 | return err 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /common/rest/client.go: -------------------------------------------------------------------------------- 1 | // Package rest provides a simple HTTP and REST request builder and client. 2 | package rest 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | 13 | "gopkg.in/yaml.v2" 14 | 15 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/rest/helpers" 16 | ) 17 | 18 | // ErrEmptyResponseBody means the client receives an unexpected empty response from server 19 | var ErrEmptyResponseBody = errors.New("empty response body") 20 | var bufferSize = 1024 21 | 22 | // ErrorResponse is the status code and response received from the server when an error occurs. 23 | type ErrorResponse struct { 24 | StatusCode int // Response status code 25 | Message string // Response text 26 | } 27 | 28 | func (e *ErrorResponse) Error() string { 29 | return fmt.Sprintf("Error response from server. Status code: %v; message: %v", e.StatusCode, e.Message) 30 | } 31 | 32 | // Client is a simple HTTP and REST client. Create it with NewClient method. 33 | type Client struct { 34 | HTTPClient *http.Client // HTTP client, default is HTTP DefaultClient 35 | DefaultHeader http.Header // Default header applied to all outgoing HTTP request. 36 | } 37 | 38 | // NewClient creates a client. 39 | func NewClient() *Client { 40 | return &Client{ 41 | HTTPClient: http.DefaultClient, 42 | DefaultHeader: make(http.Header), 43 | } 44 | } 45 | 46 | // DoWithContext sends a request and returns a HTTP response whose body is consumed and 47 | // closed. The context controls the lifetime of the outgoing request and its response. 48 | // 49 | // If respV is not nil, the value it points to is JSON decoded when server 50 | // returns a successful response. 51 | // 52 | // If errV is not nil, the value it points to is JSON decoded when server 53 | // returns an unsuccessfully response. If the response text is not a JSON 54 | // string, a more generic ErrorResponse error is returned. 55 | func (c *Client) DoWithContext(ctx context.Context, r *Request, respV interface{}, errV interface{}) (*http.Response, error) { 56 | req, err := c.makeRequest(ctx, r) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | client := c.HTTPClient 62 | if client == nil { 63 | client = http.DefaultClient 64 | } 65 | 66 | req.Close = true 67 | resp, err := client.Do(req) 68 | if err != nil { 69 | return resp, err 70 | } 71 | defer func() error { 72 | if err := resp.Body.Close(); err != nil { 73 | return err 74 | } 75 | return nil 76 | }() 77 | 78 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 79 | raw, err := ioutil.ReadAll(resp.Body) 80 | if err != nil { 81 | return resp, fmt.Errorf("Error reading response: %v", err) 82 | } 83 | 84 | if len(raw) > 0 && errV != nil { 85 | if json.Unmarshal(raw, errV) == nil { 86 | return resp, nil 87 | } 88 | } 89 | 90 | return resp, &ErrorResponse{resp.StatusCode, string(raw)} 91 | } 92 | 93 | if respV != nil { 94 | switch respV.(type) { 95 | case io.Writer: 96 | _, err = io.Copy(respV.(io.Writer), resp.Body) 97 | default: 98 | // Determine the response type and the decoder that should be used. 99 | // If buffer is identified as json, use JSON decoder, otherwise 100 | // assume the buffer contains yaml bytes 101 | body, isJSON := IsJSONStream(resp.Body, bufferSize) 102 | if isJSON { 103 | err = json.NewDecoder(body).Decode(respV) 104 | } else { 105 | err = yaml.NewDecoder(body).Decode(respV) 106 | } 107 | // For 204 No Content we should not throw an error 108 | // if there is an empty response body 109 | if err == io.EOF && resp.StatusCode == http.StatusNoContent { 110 | err = nil 111 | } else if err == io.EOF { 112 | err = ErrEmptyResponseBody 113 | } 114 | } 115 | } 116 | 117 | return resp, err 118 | } 119 | 120 | // Do wraps DoWithContext using the background context. 121 | func (c *Client) Do(r *Request, respV interface{}, errV interface{}) (*http.Response, error) { 122 | return c.DoWithContext(context.Background(), r, respV, errV) 123 | } 124 | 125 | func (c *Client) makeRequest(ctx context.Context, r *Request) (*http.Request, error) { 126 | req, err := r.Build() 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | if ctx != nil { 132 | req = req.WithContext(ctx) 133 | } 134 | 135 | c.applyDefaultHeader(req) 136 | 137 | if req.Header.Get("Accept") == "" { 138 | req.Header.Set("Accept", "application/json") 139 | } 140 | if req.Header.Get("Content-Type") == "" { 141 | req.Header.Set("Content-Type", "application/json") 142 | } 143 | 144 | return req, nil 145 | } 146 | 147 | func (c *Client) applyDefaultHeader(req *http.Request) { 148 | for k, vs := range c.DefaultHeader { 149 | if req.Header.Get(k) != "" { 150 | continue 151 | } 152 | for _, v := range vs { 153 | req.Header.Add(k, v) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /common/rest/client_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var noContentHandler = func(w http.ResponseWriter, r *http.Request) { 15 | w.WriteHeader(http.StatusNoContent) 16 | } 17 | 18 | func serveHandler(statusCode int, message string) http.HandlerFunc { 19 | return func(w http.ResponseWriter, r *http.Request) { 20 | w.WriteHeader(statusCode) 21 | fmt.Fprint(w, message) 22 | } 23 | } 24 | 25 | func TestDoWithContext_WithSuccessV(t *testing.T) { 26 | assert := assert.New(t) 27 | 28 | ts := httptest.NewServer(serveHandler(200, "{\"foo\": \"bar\"}")) 29 | defer ts.Close() 30 | 31 | var res map[string]string 32 | resp, err := NewClient().DoWithContext(context.TODO(), GetRequest(ts.URL), &res, nil) 33 | assert.NoError(err) 34 | assert.Equal(http.StatusOK, resp.StatusCode) 35 | assert.Equal("bar", res["foo"]) 36 | } 37 | 38 | func TestDoWithContext_WithSuccessYAML(t *testing.T) { 39 | assert := assert.New(t) 40 | 41 | ts := httptest.NewServer(serveHandler(200, "\"foo\": \"bar\"")) 42 | defer ts.Close() 43 | 44 | var res map[string]string 45 | resp, err := NewClient().DoWithContext(context.TODO(), GetRequest(ts.URL), &res, nil) 46 | assert.NoError(err) 47 | assert.Equal(http.StatusOK, resp.StatusCode) 48 | assert.Equal("bar", res["foo"]) 49 | } 50 | 51 | func TestDoWithContext_WithSuccessComplexYAML(t *testing.T) { 52 | assert := assert.New(t) 53 | 54 | ts := httptest.NewServer(serveHandler(200, ` 55 | --- 56 | doe: "a deer, a female deer" 57 | ray: "a drop of golden sun" 58 | pi: 3.14159 59 | xmas: true 60 | french-hens: 3 61 | calling-birds: 62 | - huey 63 | - dewey 64 | - louie 65 | - fred`)) 66 | defer ts.Close() 67 | 68 | var res map[string]interface{} 69 | resp, err := NewClient().DoWithContext(context.TODO(), GetRequest(ts.URL), &res, nil) 70 | assert.NoError(err) 71 | assert.Equal(http.StatusOK, resp.StatusCode) 72 | assert.Equal("a deer, a female deer", res["doe"]) 73 | assert.Equal("a drop of golden sun", res["ray"]) 74 | assert.Equal(3.14159, res["pi"]) 75 | assert.Equal(true, res["xmas"]) 76 | callingBirds := []interface{}{"huey", "dewey", "louie", "fred"} 77 | assert.Equal(callingBirds, res["calling-birds"]) 78 | } 79 | 80 | func TestDoWithContext_NilContext_WithSuccessV(t *testing.T) { 81 | assert := assert.New(t) 82 | 83 | ts := httptest.NewServer(serveHandler(200, "{\"foo\": \"bar\"}")) 84 | defer ts.Close() 85 | 86 | var res map[string]string 87 | resp, err := NewClient().DoWithContext(nil, GetRequest(ts.URL), &res, nil) 88 | assert.NoError(err) 89 | assert.Equal(http.StatusOK, resp.StatusCode) 90 | assert.Equal("bar", res["foo"]) 91 | } 92 | 93 | func TestDoWithContext_NilContext_WithSuccessYAML(t *testing.T) { 94 | assert := assert.New(t) 95 | 96 | ts := httptest.NewServer(serveHandler(200, "\"foo\": \"bar\"")) 97 | defer ts.Close() 98 | 99 | var res map[string]string 100 | resp, err := NewClient().DoWithContext(nil, GetRequest(ts.URL), &res, nil) 101 | assert.NoError(err) 102 | assert.Equal(http.StatusOK, resp.StatusCode) 103 | assert.Equal("bar", res["foo"]) 104 | } 105 | 106 | func TestDoWithContext_WithoutSuccessV(t *testing.T) { 107 | assert := assert.New(t) 108 | 109 | ts := httptest.NewServer(serveHandler(200, "abcedefg")) 110 | defer ts.Close() 111 | 112 | resp, err := NewClient().Do(GetRequest(ts.URL), nil, nil) 113 | assert.NoError(err) 114 | assert.Equal(http.StatusOK, resp.StatusCode) 115 | } 116 | 117 | func TestDo_WithSuccessV(t *testing.T) { 118 | assert := assert.New(t) 119 | 120 | ts := httptest.NewServer(serveHandler(200, "{\"foo\": \"bar\"}")) 121 | defer ts.Close() 122 | 123 | var res map[string]string 124 | resp, err := NewClient().Do(GetRequest(ts.URL), &res, nil) 125 | assert.NoError(err) 126 | assert.Equal(http.StatusOK, resp.StatusCode) 127 | assert.Equal("bar", res["foo"]) 128 | } 129 | 130 | func TestDo_WithSuccessYAML(t *testing.T) { 131 | assert := assert.New(t) 132 | 133 | ts := httptest.NewServer(serveHandler(200, "\"foo\": \"bar\"")) 134 | defer ts.Close() 135 | 136 | var res map[string]string 137 | resp, err := NewClient().Do(GetRequest(ts.URL), &res, nil) 138 | assert.NoError(err) 139 | assert.Equal(http.StatusOK, resp.StatusCode) 140 | assert.Equal("bar", res["foo"]) 141 | } 142 | 143 | func TestDo_WithoutSuccessV(t *testing.T) { 144 | assert := assert.New(t) 145 | 146 | ts := httptest.NewServer(serveHandler(200, "abcedefg")) 147 | defer ts.Close() 148 | 149 | resp, err := NewClient().Do(GetRequest(ts.URL), nil, nil) 150 | assert.NoError(err) 151 | assert.Equal(http.StatusOK, resp.StatusCode) 152 | } 153 | 154 | func TestDo_ServerError_WithoutErrorV(t *testing.T) { 155 | assert := assert.New(t) 156 | 157 | code := 500 158 | errResp := "Internal server error." 159 | 160 | ts := httptest.NewServer(serveHandler(code, errResp)) 161 | defer ts.Close() 162 | 163 | var successV interface{} 164 | _, err := NewClient().Do(GetRequest(ts.URL), &successV, nil) 165 | assert.Nil(successV) 166 | assert.Error(err) 167 | assert.Equal(err, &ErrorResponse{code, errResp}) 168 | } 169 | 170 | func TestDo_ServerError_WithErrorV(t *testing.T) { 171 | assert := assert.New(t) 172 | 173 | code := 500 174 | errResp := "{\"message\": \"Internal server error.\"}" 175 | 176 | ts := httptest.NewServer(serveHandler(code, errResp)) 177 | defer ts.Close() 178 | 179 | var successV interface{} 180 | var errorV = struct { 181 | Message string 182 | }{} 183 | 184 | _, err := NewClient().Do(GetRequest(ts.URL), &successV, &errorV) 185 | assert.NoError(err) 186 | assert.Equal(errorV.Message, "Internal server error.") 187 | } 188 | 189 | func TestNoContent(t *testing.T) { 190 | assert := assert.New(t) 191 | 192 | ts := httptest.NewServer(http.HandlerFunc(noContentHandler)) 193 | defer ts.Close() 194 | 195 | req := GetRequest(ts.URL) 196 | 197 | var successV interface{} 198 | _, err := NewClient().Do(req, &successV, nil) 199 | assert.NoError(err) // no empty response body error 200 | } 201 | 202 | func TestDownloadFile(t *testing.T) { 203 | assert := assert.New(t) 204 | 205 | ts := httptest.NewServer(serveHandler(200, "abcedefg")) 206 | defer ts.Close() 207 | 208 | f, err := ioutil.TempFile("", "BluemixCliRestTest") 209 | assert.NoError(err) 210 | defer f.Close() 211 | 212 | _, err = NewClient().Do(GetRequest(ts.URL), f, nil) 213 | assert.NoError(err) 214 | bytes, _ := ioutil.ReadFile(f.Name()) 215 | assert.Equal("abcedefg", string(bytes)) 216 | } 217 | -------------------------------------------------------------------------------- /common/rest/helpers/client_helpers.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "unicode" 8 | ) 9 | 10 | var jsonPrefix = []byte("{") 11 | var jsonArrayPrefix = []byte("[") 12 | 13 | // IsJSONStream scans the provided reader up to size, looking 14 | // for a json prefix indicating this is JSON. It will return the 15 | // bufio.Reader it creates for the consumer. The buffer size will at either be the size of 'size' 16 | // or the size of the Reader passed in 'r', whichever is larger. 17 | // Inspired from https://github.com/kubernetes/apimachinery/blob/v0.21.0/pkg/util/yaml/decoder.go 18 | func IsJSONStream(r io.Reader, size int) (io.Reader, bool) { 19 | buffer := bufio.NewReaderSize(r, size) 20 | b, _ := buffer.Peek(size) 21 | return buffer, containsJSON(b) 22 | } 23 | 24 | // containsJSON returns true if the provided buffer appears to start with 25 | // a JSON open brace or JSON open bracket. 26 | // Inspired from https://github.com/kubernetes/apimachinery/blob/v0.21.0/pkg/util/yaml/decoder.go 27 | func containsJSON(buf []byte) bool { 28 | return hasPrefix(buf, jsonPrefix) || hasPrefix(buf, jsonArrayPrefix) 29 | } 30 | 31 | // Return true if the first non-whitespace bytes in buf is 32 | // prefix. 33 | func hasPrefix(buf []byte, prefix []byte) bool { 34 | trim := bytes.TrimLeftFunc(buf, unicode.IsSpace) 35 | return bytes.HasPrefix(trim, prefix) 36 | } 37 | -------------------------------------------------------------------------------- /common/rest/helpers/client_helpers_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var chunkSize int 12 | 13 | func TestIsJSONStreamWithJSON(t *testing.T) { 14 | assert := assert.New(t) 15 | 16 | chunkSize = 1024 17 | jsonBytes := []byte(`{ 18 | "foo": { 19 | "bar": 1 20 | "otherStuff": 2 21 | } 22 | }`) 23 | jsonBytesAsString := string(jsonBytes) 24 | 25 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 26 | 27 | assert.NotNil(r) 28 | 29 | raw, _ := ioutil.ReadAll(r) 30 | 31 | assert.NotNil(raw) 32 | assert.True(isJSON) 33 | assert.Equal(jsonBytesAsString, string(raw)) 34 | } 35 | 36 | func TestIsJSONStreamWithJSONArray(t *testing.T) { 37 | assert := assert.New(t) 38 | 39 | chunkSize = 1024 40 | jsonBytes := []byte(`[ 41 | { 42 | "foo": { 43 | "bar": 1 44 | "otherStuff": 2 45 | } 46 | }, 47 | { 48 | "foo2": { 49 | "bar": 1 50 | "otherStuff": 2 51 | } 52 | } 53 | ]`) 54 | jsonBytesAsString := string(jsonBytes) 55 | 56 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 57 | 58 | assert.NotNil(r) 59 | 60 | raw, _ := ioutil.ReadAll(r) 61 | 62 | assert.NotNil(raw) 63 | assert.True(isJSON) 64 | assert.Equal(jsonBytesAsString, string(raw)) 65 | } 66 | 67 | func TestIsJSONStreamWithSpaceJSON(t *testing.T) { 68 | assert := assert.New(t) 69 | 70 | chunkSize = 1024 71 | jsonBytes := []byte("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + 72 | "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + 73 | "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\t\t\t\t\t\t\t\t\t" + 74 | "\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" + 75 | "\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" + 76 | "\t\t\t\t\t\t\t\t\t\t{\"foo\": \"bar\"}") 77 | jsonBytesAsString := string(jsonBytes) 78 | 79 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 80 | 81 | assert.NotNil(r) 82 | 83 | raw, _ := ioutil.ReadAll(r) 84 | 85 | assert.NotNil(raw) 86 | assert.True(isJSON) 87 | assert.Equal(jsonBytesAsString, string(raw)) 88 | } 89 | 90 | func TestIsJSONStreamWithInvalidJSON(t *testing.T) { 91 | assert := assert.New(t) 92 | 93 | chunkSize = 1024 94 | jsonBytes := []byte(` 95 | "foo": { 96 | "bar": 1 97 | "otherStuff": 2 98 | } 99 | `) 100 | jsonBytesAsString := string(jsonBytes) 101 | 102 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 103 | 104 | assert.NotNil(r) 105 | 106 | raw, _ := ioutil.ReadAll(r) 107 | 108 | assert.NotNil(raw) 109 | assert.False(isJSON) 110 | assert.Equal(jsonBytesAsString, string(raw)) 111 | } 112 | 113 | func TestIsJSONStreamWithString(t *testing.T) { 114 | assert := assert.New(t) 115 | 116 | chunkSize = 1024 117 | jsonBytes := []byte("foobar") 118 | jsonBytesAsString := string(jsonBytes) 119 | 120 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 121 | 122 | assert.NotNil(r) 123 | 124 | raw, _ := ioutil.ReadAll(r) 125 | 126 | assert.NotNil(raw) 127 | assert.False(isJSON) 128 | assert.Equal(jsonBytesAsString, string(raw)) 129 | } 130 | 131 | func TestIsJSONStreamWithYAML(t *testing.T) { 132 | assert := assert.New(t) 133 | 134 | chunkSize = 1024 135 | jsonBytes := []byte(`--- 136 | foo: 137 | - bar: jon_snow 138 | description: "The king of the north" 139 | created: 2016-01-14 140 | updated: 2019-08-16 141 | company: IBM 142 | `) 143 | jsonBytesAsString := string(jsonBytes) 144 | 145 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 146 | 147 | assert.NotNil(r) 148 | 149 | raw, _ := ioutil.ReadAll(r) 150 | 151 | assert.NotNil(raw) 152 | assert.False(isJSON) 153 | assert.Equal(jsonBytesAsString, string(raw)) 154 | } 155 | 156 | func TestIsJSONStreamWithYamlArray(t *testing.T) { 157 | assert := assert.New(t) 158 | 159 | chunkSize = 1024 160 | jsonBytes := []byte(` 161 | items: 162 | - things: 163 | thing1: huey 164 | things2: dewey 165 | thing3: louie 166 | - other things: 167 | key: value 168 | `) 169 | jsonBytesAsString := string(jsonBytes) 170 | 171 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 172 | 173 | assert.NotNil(r) 174 | 175 | raw, _ := ioutil.ReadAll(r) 176 | 177 | assert.NotNil(raw) 178 | assert.False(isJSON) 179 | assert.Equal(jsonBytesAsString, string(raw)) 180 | } 181 | 182 | func TestIsJSONStreamWithEmptyString(t *testing.T) { 183 | assert := assert.New(t) 184 | 185 | chunkSize = 1024 186 | jsonBytes := []byte("") 187 | jsonBytesAsString := string(jsonBytes) 188 | 189 | r, isJSON := IsJSONStream(bytes.NewReader(jsonBytes), chunkSize) 190 | 191 | assert.NotNil(r) 192 | 193 | raw, _ := ioutil.ReadAll(r) 194 | 195 | assert.NotNil(raw) 196 | assert.False(isJSON) 197 | assert.Equal(jsonBytesAsString, string(raw)) 198 | } 199 | -------------------------------------------------------------------------------- /common/rest/request_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/models" 11 | . "github.com/IBM-Cloud/ibm-cloud-cli-sdk/common/rest" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestRequestQueryParam(t *testing.T) { 17 | assert := assert.New(t) 18 | 19 | req, err := GetRequest("http://www.example.com?foo=fooVal1"). 20 | Query("foo", "fooVal2"). 21 | Query("bar", "bar Val"). 22 | Build() 23 | 24 | assert.NoError(err) 25 | assert.Contains(req.URL.String(), "foo=fooVal1") 26 | assert.Contains(req.URL.String(), "foo=fooVal2") 27 | assert.Contains(req.URL.String(), "bar=bar+Val") 28 | } 29 | 30 | func TestRequestHeader(t *testing.T) { 31 | assert := assert.New(t) 32 | 33 | req, err := GetRequest("http://www.example.com"). 34 | Set("Accept", "application/json"). 35 | Add("Accept-Encoding", "compress"). 36 | Add("Accept-Encoding", "gzip"). 37 | Build() 38 | 39 | assert.NoError(err) 40 | assert.Equal("application/json", req.Header.Get("Accept")) 41 | assert.Equal([]string{"compress", "gzip"}, req.Header["Accept-Encoding"]) 42 | } 43 | 44 | func TestRequestFormText(t *testing.T) { 45 | assert := assert.New(t) 46 | 47 | req, err := PostRequest("http://www.example.com"). 48 | Field("foo", "bar"). 49 | Build() 50 | 51 | assert.NoError(err) 52 | err = req.ParseForm() 53 | assert.NoError(err) 54 | assert.Equal(FormUrlEncodedContentType, req.Header.Get(ContentType)) 55 | assert.Equal("bar", req.FormValue("foo")) 56 | } 57 | 58 | func TestRequestFormMultipart(t *testing.T) { 59 | assert := assert.New(t) 60 | 61 | var prepareFileWithContent = func(text string) (*os.File, error) { 62 | f, err := os.CreateTemp("", "BluemixCliRestTest") 63 | if err != nil { 64 | return nil, err 65 | } 66 | _, err = f.WriteString(text) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | _, err = f.Seek(0, 0) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | return f, err 77 | } 78 | 79 | f, err := prepareFileWithContent("12345") 80 | assert.NoError(err) 81 | 82 | req, err := PostRequest("http://www.example.com"). 83 | Field("foo", "bar"). 84 | File("file1", File{Name: f.Name(), Content: f}). 85 | File("file2", File{Name: "file2.txt", Content: strings.NewReader("abcde"), Type: "text/plain"}). 86 | Build() 87 | 88 | assert.NoError(err) 89 | assert.Contains(req.Header.Get(ContentType), "multipart/form-data") 90 | 91 | err = req.ParseMultipartForm(int64(5000)) 92 | assert.NoError(err) 93 | 94 | assert.Equal(1, len(req.MultipartForm.Value)) 95 | assert.Equal("bar", req.MultipartForm.Value["foo"][0]) 96 | 97 | assert.Equal(2, len(req.MultipartForm.File)) 98 | 99 | assert.Equal(1, len(req.MultipartForm.File["file1"])) 100 | // As of Golang 1.17, a temp file always has a directory, so compare the string suffix that is the filename 101 | assert.True(strings.HasSuffix(f.Name(), req.MultipartForm.File["file1"][0].Filename)) 102 | assert.Equal("application/octet-stream", req.MultipartForm.File["file1"][0].Header.Get("Content-Type")) 103 | 104 | assert.Equal(1, len(req.MultipartForm.File["file2"])) 105 | assert.Equal("file2.txt", req.MultipartForm.File["file2"][0].Filename) 106 | assert.Equal("text/plain", req.MultipartForm.File["file2"][0].Header.Get("Content-Type")) 107 | 108 | b1 := new(bytes.Buffer) 109 | f1, _ := req.MultipartForm.File["file1"][0].Open() 110 | io.Copy(b1, f1) 111 | assert.Equal("12345", string(b1.Bytes())) 112 | 113 | b2 := new(bytes.Buffer) 114 | f2, _ := req.MultipartForm.File["file2"][0].Open() 115 | io.Copy(b2, f2) 116 | assert.Equal("abcde", string(b2.Bytes())) 117 | } 118 | 119 | func TestRequestJSON(t *testing.T) { 120 | assert := assert.New(t) 121 | 122 | var foo = struct { 123 | Name string 124 | }{ 125 | Name: "bar", 126 | } 127 | 128 | req, err := PostRequest("http://www.example.com").Body(&foo).Build() 129 | assert.NoError(err) 130 | body, err := io.ReadAll(req.Body) 131 | assert.NoError(err) 132 | assert.Equal("{\"Name\":\"bar\"}", string(body)) 133 | } 134 | 135 | func TestCachedPaginationNextURL(t *testing.T) { 136 | testCases := []struct { 137 | name string 138 | paginationURLs []models.PaginationURL 139 | offset int 140 | expectedNextURL string 141 | }{ 142 | { 143 | name: "return cached next URL", 144 | offset: 200, 145 | paginationURLs: []models.PaginationURL{ 146 | { 147 | NextURL: "/v2/example.com/stuff?limit=100", 148 | LastIndex: 100, 149 | }, 150 | }, 151 | expectedNextURL: "/v2/example.com/stuff?limit=100", 152 | }, 153 | { 154 | name: "return empty string if cache URL cannot be determined", 155 | offset: 40, 156 | paginationURLs: []models.PaginationURL{ 157 | { 158 | NextURL: "/v2/example.com/stuff?limit=100", 159 | LastIndex: 60, 160 | }, 161 | }, 162 | expectedNextURL: "", 163 | }, 164 | { 165 | name: "return empty string if no cache available", 166 | offset: 40, 167 | paginationURLs: []models.PaginationURL{}, 168 | expectedNextURL: "", 169 | }, 170 | } 171 | 172 | assert := assert.New(t) 173 | 174 | for _, tc := range testCases { 175 | t.Run(tc.name, func(_ *testing.T) { 176 | p := CachedPaginationNextURL(tc.paginationURLs, tc.offset) 177 | assert.Equal(tc.expectedNextURL, p.NextURL) 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /common/types/time.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | type UnixTime time.Time 9 | 10 | func (t UnixTime) Time() time.Time { 11 | return time.Time(t) 12 | } 13 | 14 | func (t UnixTime) MarshalJSON() ([]byte, error) { 15 | return json.Marshal(t.Time().Unix()) 16 | } 17 | 18 | func (t *UnixTime) UnmarshalJSON(bytes []byte) error { 19 | var sec int64 20 | if err := json.Unmarshal(bytes, &sec); err != nil { 21 | return err 22 | } 23 | *t = UnixTime(time.Unix(sec, 0)) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/IBM-Cloud/ibm-cloud-cli-sdk 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.6 6 | 7 | require ( 8 | github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886 9 | github.com/fatih/structs v1.0.1-0.20171020064819-f5faa72e7309 10 | github.com/gofrs/flock v0.8.1 11 | github.com/jedib0t/go-pretty/v6 v6.6.1 12 | github.com/mattn/go-colorable v0.0.0-20160210001857-9fdad7c47650 13 | github.com/mattn/go-runewidth v0.0.15 14 | github.com/nicksnyder/go-i18n/v2 v2.2.0 15 | github.com/onsi/gomega v1.33.0 16 | github.com/spf13/cobra v1.6.1 17 | github.com/spf13/pflag v1.0.5 18 | github.com/stretchr/testify v1.8.4 19 | golang.org/x/crypto v0.36.0 20 | golang.org/x/term v0.30.0 21 | golang.org/x/text v0.23.0 22 | gopkg.in/cheggaaa/pb.v1 v1.0.15 23 | gopkg.in/yaml.v2 v2.4.0 24 | ) 25 | 26 | require ( 27 | github.com/BurntSushi/toml v1.1.0 // indirect 28 | github.com/davecgh/go-spew v1.1.1 // indirect 29 | github.com/google/go-cmp v0.6.0 // indirect 30 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 31 | github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035 // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/rivo/uniseg v0.2.0 // indirect 34 | golang.org/x/net v0.38.0 // indirect 35 | golang.org/x/sys v0.31.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 2 | github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= 3 | github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886 h1:NAFoy+QgUpERgK3y1xiVh5HcOvSeZHpXTTo5qnvnuK4= 8 | github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 9 | github.com/fatih/structs v1.0.1-0.20171020064819-f5faa72e7309 h1:e3z/5nE0uPKuqOc75vXcdV513niF/KDgDddVC8eF9MM= 10 | github.com/fatih/structs v1.0.1-0.20171020064819-f5faa72e7309/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 11 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 12 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 13 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 14 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 15 | github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= 16 | github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= 20 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= 21 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 22 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 23 | github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc= 24 | github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= 25 | github.com/mattn/go-colorable v0.0.0-20160210001857-9fdad7c47650 h1:pwtfAm8Do0gwFJ2J+iUrEVR9qI03BpDSuDQCIqbd6iY= 26 | github.com/mattn/go-colorable v0.0.0-20160210001857-9fdad7c47650/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 27 | github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035 h1:USWjF42jDCSEeikX/G1g40ZWnsPXN5WkZ4jMHZWyBK4= 28 | github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 29 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 30 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 31 | github.com/nicksnyder/go-i18n/v2 v2.2.0 h1:MNXbyPvd141JJqlU6gJKrczThxJy+kdCNivxZpBQFkw= 32 | github.com/nicksnyder/go-i18n/v2 v2.2.0/go.mod h1:4OtLfzqyAxsscyCb//3gfqSvBc81gImX91LrZzczN1o= 33 | github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= 34 | github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= 35 | github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= 36 | github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 40 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 41 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 42 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= 43 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= 44 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 45 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 46 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 47 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 48 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 49 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 50 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 51 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 52 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 53 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 54 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 55 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 56 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 57 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 58 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 59 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 60 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 61 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/cheggaaa/pb.v1 v1.0.15 h1:1WP0I1XIkfylrOuo3YeOAt4QXsvESM1enkg3vH6FDmI= 65 | gopkg.in/cheggaaa/pb.v1 v1.0.15/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= 66 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 67 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 68 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 69 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 70 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 71 | -------------------------------------------------------------------------------- /i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/nicksnyder/go-i18n/v2/i18n" 11 | "golang.org/x/text/language" 12 | 13 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/resources" 14 | ) 15 | 16 | const ( 17 | defaultLocale = "en_US" 18 | resourcesSuffix = ".json" 19 | resourcesPrefix = "all." 20 | ) 21 | 22 | var ( 23 | bundle *i18n.Bundle 24 | T TranslateFunc 25 | RESOURCE_PATH = filepath.Join("i18n", "resources") 26 | ) 27 | 28 | func init() { 29 | bundle = Bundle() 30 | resource := resourcesPrefix + defaultLocale + resourcesSuffix 31 | loadAsset(filepath.Join(RESOURCE_PATH, resource)) 32 | T = MustTfunc(defaultLocale) 33 | } 34 | 35 | // Bundle returns an instance of i18n.bundle 36 | func Bundle() *i18n.Bundle { 37 | if bundle == nil { 38 | bundle = i18n.NewBundle(language.AmericanEnglish) 39 | bundle.RegisterUnmarshalFunc("json", json.Unmarshal) 40 | } 41 | return bundle 42 | } 43 | 44 | // Translate returns a method based on translate method signature in v1.3.0. 45 | // To allow compatability between v1.3.0 and v2.0+, the `messageId` and `args` parameters are 46 | // processed to fit with the new Localize API method. 47 | // @see https://github.com/nicksnyder/go-i18n/blob/v1.3.0/i18n/bundle/bundle.go#L227-L257 for more 48 | // information on the translate method 49 | func Translate(loc *i18n.Localizer) TranslateFunc { 50 | return func(messageId string, args ...interface{}) string { 51 | var pluralCount interface{} 52 | var templateData interface{} 53 | 54 | /** 55 | * For the common usecases we can expect two scenarios. Below are two examples: 56 | * 1) T("Cannot login in region {{.REGION}}", map[string]interface{}{"REGION": "us-south"}) 57 | * 2) T("login_fail_count", "2", map[string]interface{}{"Command": "ibmcloud login"}) 58 | * 59 | * First paramter is always the `messageId` 60 | * Second paramter can be either pluralCount or templateData. 61 | * Third parameter can be templateData only if the second paramters is the plural count 62 | 63 | * If we have 2 args than we should expect scenario 2, otherwise we will assume scenario 1 64 | */ 65 | if argc := len(args); argc > 0 { 66 | if isNumber(args[0]) { 67 | pluralCount = args[0] 68 | if argc > 1 { 69 | templateData = args[1] 70 | } 71 | } else { 72 | templateData = args[0] 73 | } 74 | 75 | } 76 | 77 | msg, _ := loc.Localize(&i18n.LocalizeConfig{ 78 | MessageID: messageId, 79 | TemplateData: templateData, 80 | PluralCount: pluralCount, 81 | }) 82 | 83 | // If no message is returned we can assume that that 84 | // the translation could not be found in any of the files 85 | // Set the message as the messageID 86 | if msg == "" { 87 | msg = messageId 88 | } 89 | return msg 90 | 91 | } 92 | } 93 | 94 | // TranslateFunc returns the translation of the string identified by translationID. 95 | // @see https://github.com/nicksnyder/go-i18n/blob/v1.3.0/i18n/bundle/bundle.go#L19 96 | type TranslateFunc func(translateID string, args ...interface{}) string 97 | 98 | func MustTfunc(sources ...string) TranslateFunc { 99 | defaultLocalizer := i18n.NewLocalizer(bundle, defaultLocale) 100 | defaultTfunc := Translate(defaultLocalizer) 101 | 102 | supportedLocales, supportedLocalToAsssetMap := supportedLocales() 103 | 104 | for _, source := range sources { 105 | if source == "" { 106 | continue 107 | } 108 | 109 | if source == defaultLocale { 110 | return defaultTfunc 111 | } 112 | 113 | lang, _ := language.Parse(source) 114 | matcher := language.NewMatcher(supportedLocales) 115 | tag, _ := language.MatchStrings(matcher, lang.String()) 116 | assetName, found := supportedLocalToAsssetMap[tag.String()] 117 | 118 | if found { 119 | loadAsset(assetName) 120 | localizer := i18n.NewLocalizer(bundle, source) 121 | return Translate(localizer) 122 | } 123 | 124 | } 125 | 126 | return defaultTfunc 127 | } 128 | 129 | func loadAsset(assetName string) { 130 | bytes, assetErr := resources.Asset(assetName) 131 | if assetErr != nil { 132 | panic(fmt.Sprintf("Could not load asset '%s': %s", assetName, assetErr.Error())) 133 | } 134 | 135 | if _, parseErr := bundle.ParseMessageFileBytes(bytes, assetName); parseErr != nil { 136 | panic(fmt.Sprintf("Could not load translations '%s': %s", assetName, parseErr.Error())) 137 | } 138 | } 139 | 140 | func supportedLocales() ([]language.Tag, map[string]string) { 141 | // When matching against supported language the first language is set as the fallback 142 | // so we will initialize the list with English as the first language 143 | // @see https://pkg.go.dev/golang.org/x/text/language#hdr-Matching_preferred_against_supported_languages for more information 144 | l := []language.Tag{language.English} 145 | m := make(map[string]string) 146 | for _, assetName := range resources.AssetNames() { 147 | // Remove the "all." prefix and ".json" suffix to get language/locale 148 | locale := normalizeLocale(strings.TrimSuffix(path.Base(assetName), ".json")) 149 | locale = strings.TrimPrefix(locale, "all.") 150 | 151 | if !strings.Contains(locale, normalizeLocale(defaultLocale)) { 152 | lang, _ := language.Parse(locale) 153 | l = append(l, lang) 154 | m[lang.String()] = assetName 155 | } 156 | } 157 | return l, m 158 | } 159 | 160 | func normalizeLocale(locale string) string { 161 | return strings.ToLower(strings.Replace(locale, "_", "-", -1)) 162 | } 163 | 164 | func isNumber(n interface{}) bool { 165 | switch n.(type) { 166 | case int, int8, int16, int32, int64, string: 167 | return true 168 | } 169 | return false 170 | } 171 | -------------------------------------------------------------------------------- /i18n/resources/all.de_DE.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\nGeben Sie eine Zahl ein." 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "Beim Erstellen der Protokolldatei '{{.Path}}' ist ein Fehler aufgetreten:\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "Bei der Anforderung zum Erstellen eines Speicherauszugs ist ein Fehler aufgetreten:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "Bei der Antwort bezüglich der Erstellung eines Speicherauszugs ist ein Fehler aufgetreten:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "Verstrichen:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "FEHLGESCHLAGEN" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "Ungültiges Token: " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "OK" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "Geben Sie 'j', 'n', 'ja' oder 'nein' ein." 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "Geben Sie eine Zahl zwischen 1 und {{.Count}} ein." 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "Geben Sie eine gültige Gleitkommazahl ein." 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "Geben Sie eine gültige Zahl ein." 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "Geben Sie einen Wert ein." 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "ANFORDERUNG:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "ANTWORT:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "Fehler auf dem fernen Server. Statuscode: {{.StatusCode}}, Fehlercode: {{.ErrorCode}}, Nachricht: {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "Speichern der Plug-in-Konfiguration nicht möglich: " 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.en_US.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\nEnter a number" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "An error occurred while dumping request:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "An error occurred while dumping response:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "Elapsed:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "FAILED" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "Invalid token: " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "OK" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "Please enter 'y', 'n', 'yes' or 'no'." 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "Please enter a number between 1 to {{.Count}}." 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "Please enter a valid floating number." 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "Please enter a valid number." 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "Please enter value." 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "REQUEST:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "RESPONSE:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "Unable to save plugin config: " 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.es_ES.json: -------------------------------------------------------------------------------- 1 | [ { 2 | "id": "\nEnter a number", 3 | "translation": "\nEscriba un número" 4 | }, 5 | { 6 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 7 | "translation": "Se ha producido un error al crear el archivo de registro '{{.Path}}':\n{{.Error}}\n\n" 8 | }, 9 | { 10 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 11 | "translation": "Se ha producido un error al volcar la solicitud:\n{{.Error}}\n" 12 | }, 13 | { 14 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 15 | "translation": "Se ha producido un error al volcar la respuesta:\n{{.Error}}\n" 16 | }, 17 | { 18 | "id": "Could not read from input: ", 19 | "translation": "Could not read from input: " 20 | }, 21 | { 22 | "id": "Elapsed:", 23 | "translation": "Transcurrido:" 24 | }, 25 | { 26 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 27 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 28 | }, 29 | { 30 | "id": "FAILED", 31 | "translation": "ERROR" 32 | }, 33 | { 34 | "id": "Failed, header could not convert to csv format", 35 | "translation": "Failed, header could not convert to csv format" 36 | }, 37 | { 38 | "id": "Failed, rows could not convert to csv format", 39 | "translation": "Failed, rows could not convert to csv format" 40 | }, 41 | { 42 | "id": "Invalid grant type: ", 43 | "translation": "Invalid grant type: " 44 | }, 45 | { 46 | "id": "Invalid token: ", 47 | "translation": "Señal no válida: " 48 | }, 49 | { 50 | "id": "OK", 51 | "translation": "Correcto" 52 | }, 53 | { 54 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 55 | "translation": "Especifique 'y', 'n', 'yes' o 'no'." 56 | }, 57 | { 58 | "id": "Please enter a number between 1 to {{.Count}}.", 59 | "translation": "Especifique un número entre 1 y {{.Count}}." 60 | }, 61 | { 62 | "id": "Please enter a valid floating number.", 63 | "translation": "Especifique un número flotante válido." 64 | }, 65 | { 66 | "id": "Please enter a valid number.", 67 | "translation": "Especifique un número válido." 68 | }, 69 | { 70 | "id": "Please enter value.", 71 | "translation": "Especifique un valor." 72 | }, 73 | { 74 | "id": "REQUEST:", 75 | "translation": "SOLICITUD:" 76 | }, 77 | { 78 | "id": "RESPONSE:", 79 | "translation": "RESPUESTA:" 80 | }, 81 | { 82 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 83 | "translation": "Error del servidor remoto. Código de estado: {{.StatusCode}}, código de error: {{.ErrorCode}}, mensaje: {{.Message}}" 84 | }, 85 | { 86 | "id": "Unable to save plugin config: ", 87 | "translation": "No se ha podido guardar la configuración del plugin:" 88 | }, 89 | { 90 | "id": "Session inactive: ", 91 | "translation": "Session inactive: " 92 | } 93 | ] 94 | -------------------------------------------------------------------------------- /i18n/resources/all.fr_FR.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\nEntrez un nombre" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "Erreur lors de la création du fichier journal '{{.Path}}':\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "Erreur lors de la demande de vidage :\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "Erreur lors de la réponse de vidage :\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "Ecoulé :" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "ECHEC" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "Jeton non valide : " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "OK" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "L'entrée doit être 'y', 'n', 'yes' ou 'no'." 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "Entrez un nombre entre 1 et {{.Count}}." 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "Entrez un nombre flottant valide." 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "Entrez un nombre valide." 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "Entrez une valeur." 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "DEMANDE :" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "REPONSE :" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "Erreur du serveur distant. Code de statut : {{.StatusCode}}, code d'erreur : {{.ErrorCode}}, message : {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "Impossible d'enregistrer la configuration du plug-in : " 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.it_IT.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\nImmetti un numero" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "Si è verificato un errore durante la creazione del file di log '{{.Path}}':\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "Si è verificato un errore durante il dump della richiesta:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "Si è verificato un errore durante il dump della risposta:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "Trascorso:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "NON RIUSCITO" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "Token non valido: " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "OK" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "Immetti 's', 'n', 'sì' o 'no'." 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "Immetti un numero compreso tra 1 e {{.Count}}." 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "Immetti un numero decimale valido." 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "Immetti un numero valido." 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "Immetti un valore." 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "RICHIESTA:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "RISPOSTA:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "Errore server remoto. Codice di stato: {{.StatusCode}}, codice di errore: {{.ErrorCode}}, messaggio: {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "Impossibile salvare la configurazione del plug-in: " 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.ja_JP.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\n数値を入力してください" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "ログ・ファイル '{{.Path}}' を作成中にエラーが発生しました:\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "要求のダンプ中にエラーが発生しました:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "応答のダンプ中にエラーが発生しました:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "経過:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "失敗" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "トークンが無効です: " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "OK" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "「y」、「n」、「yes」、または「no」を入力してください。" 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "1 から {{.Count}} までの数値を入力してください。" 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "有効な浮動小数点数を入力してください。" 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "有効な数値を入力してください。" 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "値を入力してください。" 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "要求:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "応答:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "リモート・サーバー・エラー。 状況コード: {{.StatusCode}}、エラー・コード: {{.ErrorCode}}、メッセージ: {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "プラグイン構成を保存できません: " 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.ko_KR.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\n번호 입력" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "'{{.Path}}' 로그 파일을 작성할 때 다음 오류가 발생했습니다. \n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "요청을 덤프할 때 다음 오류가 발생했습니다. \n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "응답을 덤프할 때 다음 오류가 발생했습니다. \n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "경과 시간:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "실패" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "올바르지 않은 토큰: " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "확인" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "'y', 'n', '예' 또는 '아니오'를 입력하십시오." 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "1 - {{.Count}} 사이의 수를 입력하십시오." 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "올바른 float 수를 입력하십시오." 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "올바른 수를 입력하십시오." 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "값을 입력하십시오." 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "요청:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "응답:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "원격 서버 오류가 발생했습니다. 상태 코드: {{.StatusCode}}, 오류 코드: {{.ErrorCode}}, 메시지: {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "플러그인 구성을 저장할 수 없음:" 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.pt_BR.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\nInsira um número" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "Ocorreu um erro ao criar o arquivo de log '{{.Path}}':\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "Ocorreu um erro ao fazer dump da solicitação:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "Ocorreu um erro ao fazer dump da resposta:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "Decorrido:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "COM FALHA" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "Token inválido: " 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "OK" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "Insira 'y', 'n', 'yes' ou 'no'." 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "Insira um número entre 1 e {{.Count}}." 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "Insira um número flutuante válido." 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "Digite um número válido." 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "Insira um valor." 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "SOLICITAÇÃO:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "RESPOSTA:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "Erro do servidor remoto. Código de status: {{.StatusCode}}, código de erro: {{.ErrorCode}}, mensagem: {{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "Não é possível salvar a configuração do plug-in: " 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.zh_Hans.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\n请输入数字" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "创建日志文件“{{.Path}}”时发生错误:\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "转储请求时发生错误:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "转储响应时发生错误:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "经过时长:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "失败" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "令牌无效:" 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "确定" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "请输入“y”、“n”、“yes”或“no”。" 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "请输入 1 到 {{.Count}} 之间的数字。" 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "请输入有效的浮点数。" 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "请输入有效的数字。" 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "请输入有效的值。" 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "请求: " 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "响应: " 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "远程服务器错误。状态码:{{.StatusCode}},错误代码:{{.ErrorCode}},消息:{{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "无法保存插件配置:" 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /i18n/resources/all.zh_Hant.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "\nEnter a number", 4 | "translation": "\n請輸入數字" 5 | }, 6 | { 7 | "id": "An error occurred when creating log file '{{.Path}}':\n{{.Error}}\n\n", 8 | "translation": "建立日誌檔 '{{.Path}}' 時發生錯誤:\n{{.Error}}\n\n" 9 | }, 10 | { 11 | "id": "An error occurred while dumping request:\n{{.Error}}\n", 12 | "translation": "傾出要求時發生錯誤:\n{{.Error}}\n" 13 | }, 14 | { 15 | "id": "An error occurred while dumping response:\n{{.Error}}\n", 16 | "translation": "傾出回應時發生錯誤:\n{{.Error}}\n" 17 | }, 18 | { 19 | "id": "Could not read from input: ", 20 | "translation": "Could not read from input: " 21 | }, 22 | { 23 | "id": "Elapsed:", 24 | "translation": "經歷時間:" 25 | }, 26 | { 27 | "id": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}", 28 | "translation": "External authentication failed. Error code: {{.ErrorCode}}, message: {{.Message}}" 29 | }, 30 | { 31 | "id": "FAILED", 32 | "translation": "失敗" 33 | }, 34 | { 35 | "id": "Failed, header could not convert to csv format", 36 | "translation": "Failed, header could not convert to csv format" 37 | }, 38 | { 39 | "id": "Failed, rows could not convert to csv format", 40 | "translation": "Failed, rows could not convert to csv format" 41 | }, 42 | { 43 | "id": "Invalid grant type: ", 44 | "translation": "Invalid grant type: " 45 | }, 46 | { 47 | "id": "Invalid token: ", 48 | "translation": "無效的記號:" 49 | }, 50 | { 51 | "id": "OK", 52 | "translation": "確定" 53 | }, 54 | { 55 | "id": "Please enter 'y', 'n', 'yes' or 'no'.", 56 | "translation": "請輸入 'y'、'n'、'yes' 或 'no'。" 57 | }, 58 | { 59 | "id": "Please enter a number between 1 to {{.Count}}.", 60 | "translation": "請輸入 1 到 {{.Count}} 之間的數字。" 61 | }, 62 | { 63 | "id": "Please enter a valid floating number.", 64 | "translation": "請輸入有效的浮點數。" 65 | }, 66 | { 67 | "id": "Please enter a valid number.", 68 | "translation": "請輸入有效的數字。" 69 | }, 70 | { 71 | "id": "Please enter value.", 72 | "translation": "請輸入值。" 73 | }, 74 | { 75 | "id": "REQUEST:", 76 | "translation": "要求:" 77 | }, 78 | { 79 | "id": "RESPONSE:", 80 | "translation": "回應:" 81 | }, 82 | { 83 | "id": "Remote server error. Status code: {{.StatusCode}}, error code: {{.ErrorCode}}, message: {{.Message}}", 84 | "translation": "遠端伺服器錯誤。狀態碼:{{.StatusCode}},錯誤碼:{{.ErrorCode}},訊息:{{.Message}}" 85 | }, 86 | { 87 | "id": "Unable to save plugin config: ", 88 | "translation": "無法儲存外掛程式配置:" 89 | }, 90 | { 91 | "id": "Session inactive: ", 92 | "translation": "Session inactive: " 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /plugin/plugin_context.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix" 10 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/configuration/core_config" 11 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/endpoints" 12 | ) 13 | 14 | type pluginContext struct { 15 | core_config.ReadWriter 16 | pluginConfig PluginConfig 17 | pluginPath string 18 | } 19 | 20 | func createPluginContext(pluginPath string, coreConfig core_config.ReadWriter) *pluginContext { 21 | return &pluginContext{ 22 | pluginPath: pluginPath, 23 | pluginConfig: loadPluginConfigFromPath(filepath.Join(pluginPath, "config.json")), 24 | ReadWriter: coreConfig, 25 | } 26 | } 27 | 28 | func (c *pluginContext) APIEndpoint() string { 29 | return c.ReadWriter.APIEndpoint() 30 | } 31 | 32 | func (c *pluginContext) ConsoleEndpoint() string { 33 | if c.IsPrivateEndpointEnabled() { 34 | if c.IsAccessFromVPC() { 35 | // return VPC endpoint 36 | return c.ConsoleEndpoints().PrivateVPCEndpoint 37 | } else { 38 | // return CSE endpoint 39 | return c.ConsoleEndpoints().PrivateEndpoint 40 | } 41 | } 42 | return c.ConsoleEndpoints().PublicEndpoint 43 | } 44 | 45 | // GetEndpoint returns the private or public endpoint for a requested service 46 | func (c *pluginContext) GetEndpoint(svc endpoints.Service) (string, error) { 47 | if c.CloudType() != "public" { 48 | return "", fmt.Errorf("only public cloud is supported") 49 | } 50 | 51 | if !c.HasAPIEndpoint() { 52 | return "", nil 53 | } 54 | 55 | var cloudDomain string 56 | switch cname := c.CloudName(); cname { 57 | case "bluemix": 58 | cloudDomain = "cloud.ibm.com" 59 | case "staging": 60 | cloudDomain = "test.cloud.ibm.com" 61 | default: 62 | return "", fmt.Errorf("unknown cloud name '%s'", cname) 63 | } 64 | 65 | return endpoints.Endpoint(svc, cloudDomain, c.CurrentRegion().Name, c.IsPrivateEndpointEnabled(), c.IsAccessFromVPC()) 66 | } 67 | 68 | func compareVersion(v1, v2 string) int { 69 | s1 := strings.Split(v1, ".") 70 | s2 := strings.Split(v2, ".") 71 | 72 | n := len(s1) 73 | if len(s2) > n { 74 | n = len(s2) 75 | } 76 | 77 | for i := 0; i < n; i++ { 78 | var p1, p2 int 79 | if len(s1) > i { 80 | p1, _ = strconv.Atoi(s1[i]) 81 | } 82 | if len(s2) > i { 83 | p2, _ = strconv.Atoi(s2[i]) 84 | } 85 | if p1 > p2 { 86 | return 1 87 | } 88 | if p1 < p2 { 89 | return -1 90 | } 91 | } 92 | return 0 93 | } 94 | 95 | func (c *pluginContext) HasAPIEndpoint() bool { 96 | return c.APIEndpoint() != "" 97 | } 98 | 99 | func (c *pluginContext) PluginDirectory() string { 100 | return c.pluginPath 101 | } 102 | 103 | func (c *pluginContext) PluginConfig() PluginConfig { 104 | return c.pluginConfig 105 | } 106 | 107 | func (c *pluginContext) Trace() string { 108 | return envOrConfig(bluemix.EnvTrace, c.ReadWriter.Trace()) 109 | } 110 | 111 | func (c *pluginContext) ColorEnabled() string { 112 | return envOrConfig(bluemix.EnvColor, c.ReadWriter.ColorEnabled()) 113 | } 114 | 115 | func (c *pluginContext) VersionCheckEnabled() bool { 116 | return !c.CheckCLIVersionDisabled() 117 | } 118 | 119 | func envOrConfig(env bluemix.Env, config string) string { 120 | if v := env.Get(); v != "" { 121 | return v 122 | } 123 | return config 124 | } 125 | 126 | func (c *pluginContext) CommandNamespace() string { 127 | return bluemix.EnvPluginNamespace.Get() 128 | } 129 | 130 | func (c *pluginContext) CLIName() string { 131 | return bluemix.EnvCLIName.Get() 132 | } 133 | -------------------------------------------------------------------------------- /plugin/plugin_shim.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix" 8 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/configuration/config_helpers" 9 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/configuration/core_config" 10 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" 11 | ) 12 | 13 | // Start starts the plugin. 14 | func Start(plugin Plugin) { 15 | StartWithArgs(plugin, os.Args[1:]) 16 | } 17 | 18 | // StartWithArgs starts the plugin with the given arguments. 19 | func StartWithArgs(plugin Plugin, args []string) { 20 | if isMetadataRequest(args) { 21 | metadata := fillMetadata(plugin.GetMetadata()) 22 | json, err := json.Marshal(metadata) 23 | if err != nil { 24 | panic(err) 25 | } 26 | _, err = os.Stdout.Write(json) 27 | if err != nil { 28 | panic(err) 29 | } 30 | return 31 | } 32 | 33 | context := InitPluginContext(plugin.GetMetadata().Name) 34 | 35 | // initialization 36 | i18n.T = i18n.MustTfunc(context.Locale()) 37 | 38 | plugin.Run(context, args) 39 | } 40 | 41 | func fillMetadata(metadata PluginMetadata) PluginMetadata { 42 | sdkVersion := bluemix.Version 43 | metadata.SDKVersion = VersionType{ 44 | Major: sdkVersion.Major, 45 | Minor: sdkVersion.Minor, 46 | Build: sdkVersion.Build, 47 | } 48 | return metadata 49 | } 50 | 51 | // InitPluginContext initializes a plugin context for a given plugin 52 | func InitPluginContext(pluginName string) PluginContext { 53 | coreConfig := core_config.NewCoreConfig( 54 | func(err error) { 55 | panic("configuration error: " + err.Error()) 56 | }) 57 | pluginPath := config_helpers.PluginDir(pluginName) 58 | return createPluginContext(pluginPath, coreConfig) 59 | } 60 | 61 | func isMetadataRequest(args []string) bool { 62 | return len(args) == 1 && args[0] == "SendMetadata" 63 | } 64 | -------------------------------------------------------------------------------- /plugin/plugin_shim_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/testhelpers" 10 | "github.com/spf13/cobra" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type cobraTestPlugin struct { 15 | cmd *cobra.Command 16 | metadata string 17 | } 18 | 19 | type urfaveTestPlugin struct { 20 | metadata string 21 | } 22 | 23 | func marshalMetadata(meta PluginMetadata) string { 24 | json, err := json.Marshal(meta) 25 | if err != nil { 26 | panic(fmt.Errorf("could not marshal metadata: %v", err.Error())) 27 | } 28 | 29 | return string(json) 30 | } 31 | 32 | var pluginMetadata = PluginMetadata{ 33 | Name: "test", 34 | Commands: []Command{ 35 | { 36 | Name: "list", 37 | Description: "List your apps, containers and services in the target space.", 38 | Usage: "ibmcloud list", 39 | }, 40 | }, 41 | } 42 | 43 | func (p *cobraTestPlugin) GetMetadata() PluginMetadata { 44 | p.cmd = testhelpers.GenerateCobraCommand() 45 | pluginMetadata.Commands[0].Flags = ConvertCobraFlagsToPluginFlags(p.cmd) 46 | p.metadata = marshalMetadata(fillMetadata(pluginMetadata)) 47 | 48 | return pluginMetadata 49 | } 50 | 51 | func (p *urfaveTestPlugin) GetMetadata() PluginMetadata { 52 | pluginMetadata.Commands[0].Flags = []Flag{ 53 | { 54 | Name: "output", 55 | Description: "Specify output format, only 'JSON' is supported.", 56 | Hidden: false, 57 | HasValue: true, 58 | }, 59 | } 60 | p.metadata = marshalMetadata(fillMetadata(pluginMetadata)) 61 | 62 | return pluginMetadata 63 | } 64 | 65 | func (p *cobraTestPlugin) Run(context PluginContext, args []string) {} 66 | func (p *urfaveTestPlugin) Run(context PluginContext, args []string) {} 67 | 68 | func TestStartWithArgsWithCobraCommand(t *testing.T) { 69 | orgStdout := os.Stdout 70 | stdoutMock := testhelpers.CreateMockStdout() 71 | stdoutFile := stdoutMock.File 72 | 73 | // cleanup mock 74 | defer func() { 75 | os.Stdout = orgStdout 76 | os.RemoveAll(stdoutFile.Name()) 77 | stdoutFile.Close() 78 | }() 79 | 80 | // mock stdout with empty file 81 | os.Stdout = stdoutFile 82 | 83 | cmd := []string{"SendMetadata"} 84 | pl := &cobraTestPlugin{} 85 | 86 | StartWithArgs(pl, cmd) 87 | 88 | stdoutMockOut := stdoutMock.Read() 89 | 90 | assert.Equal(t, pl.metadata, string(stdoutMockOut)) 91 | } 92 | 93 | func TestStartWithArgsWithUrfaveCommand(t *testing.T) { 94 | orgStdout := os.Stdout 95 | stdoutMock := testhelpers.CreateMockStdout() 96 | stdoutFile := stdoutMock.File 97 | 98 | // cleanup mock 99 | defer func() { 100 | os.Stdout = orgStdout 101 | os.RemoveAll(stdoutFile.Name()) 102 | stdoutFile.Close() 103 | }() 104 | 105 | // mock stdout with empty file 106 | os.Stdout = stdoutFile 107 | 108 | cmd := []string{"SendMetadata"} 109 | pl := &urfaveTestPlugin{} 110 | 111 | StartWithArgs(pl, cmd) 112 | 113 | stdoutMockOut := stdoutMock.Read() 114 | 115 | assert.Equal(t, pl.metadata, string(stdoutMockOut)) 116 | 117 | } 118 | -------------------------------------------------------------------------------- /plugin/plugin_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/testhelpers" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestConvertCobraFlagsToPluginFlags(t *testing.T) { 12 | assert := assert.New(t) 13 | cmd := testhelpers.GenerateCobraCommand() 14 | outputFlag := cmd.Flag("output") 15 | quietFlag := cmd.Flag("quiet") 16 | deprecateFlag := cmd.Flag("outputJSON") 17 | 18 | flags := ConvertCobraFlagsToPluginFlags(cmd) 19 | 20 | assert.Equal(3, len(flags)) 21 | 22 | // NOTE: flags are sorted in lexicographical order 23 | assert.Equal(outputFlag.Usage, flags[0].Description) 24 | assert.True(flags[0].HasValue) 25 | assert.Equal(outputFlag.Hidden, flags[0].Hidden) 26 | assert.Equal(outputFlag.Name, flags[0].Name) 27 | 28 | assert.Equal(deprecateFlag.Usage, flags[1].Description) 29 | assert.False(flags[1].HasValue) 30 | assert.Equal(deprecateFlag.Hidden, flags[1].Hidden) 31 | assert.Equal(deprecateFlag.Name, flags[1].Name) 32 | 33 | assert.Equal(quietFlag.Usage, flags[2].Description) 34 | assert.False(flags[2].HasValue) 35 | assert.Equal(quietFlag.Hidden, flags[2].Hidden) 36 | assert.Equal(fmt.Sprintf("%s,%s", quietFlag.Shorthand, quietFlag.Name), flags[2].Name) 37 | } 38 | -------------------------------------------------------------------------------- /plugin/pluginfakes/fake_plugin.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package pluginfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/plugin" 8 | ) 9 | 10 | type FakePlugin struct { 11 | GetMetadataStub func() plugin.PluginMetadata 12 | getMetadataMutex sync.RWMutex 13 | getMetadataArgsForCall []struct { 14 | } 15 | getMetadataReturns struct { 16 | result1 plugin.PluginMetadata 17 | } 18 | getMetadataReturnsOnCall map[int]struct { 19 | result1 plugin.PluginMetadata 20 | } 21 | RunStub func(plugin.PluginContext, []string) 22 | runMutex sync.RWMutex 23 | runArgsForCall []struct { 24 | arg1 plugin.PluginContext 25 | arg2 []string 26 | } 27 | invocations map[string][][]interface{} 28 | invocationsMutex sync.RWMutex 29 | } 30 | 31 | func (fake *FakePlugin) GetMetadata() plugin.PluginMetadata { 32 | fake.getMetadataMutex.Lock() 33 | ret, specificReturn := fake.getMetadataReturnsOnCall[len(fake.getMetadataArgsForCall)] 34 | fake.getMetadataArgsForCall = append(fake.getMetadataArgsForCall, struct { 35 | }{}) 36 | stub := fake.GetMetadataStub 37 | fakeReturns := fake.getMetadataReturns 38 | fake.recordInvocation("GetMetadata", []interface{}{}) 39 | fake.getMetadataMutex.Unlock() 40 | if stub != nil { 41 | return stub() 42 | } 43 | if specificReturn { 44 | return ret.result1 45 | } 46 | return fakeReturns.result1 47 | } 48 | 49 | func (fake *FakePlugin) GetMetadataCallCount() int { 50 | fake.getMetadataMutex.RLock() 51 | defer fake.getMetadataMutex.RUnlock() 52 | return len(fake.getMetadataArgsForCall) 53 | } 54 | 55 | func (fake *FakePlugin) GetMetadataCalls(stub func() plugin.PluginMetadata) { 56 | fake.getMetadataMutex.Lock() 57 | defer fake.getMetadataMutex.Unlock() 58 | fake.GetMetadataStub = stub 59 | } 60 | 61 | func (fake *FakePlugin) GetMetadataReturns(result1 plugin.PluginMetadata) { 62 | fake.getMetadataMutex.Lock() 63 | defer fake.getMetadataMutex.Unlock() 64 | fake.GetMetadataStub = nil 65 | fake.getMetadataReturns = struct { 66 | result1 plugin.PluginMetadata 67 | }{result1} 68 | } 69 | 70 | func (fake *FakePlugin) GetMetadataReturnsOnCall(i int, result1 plugin.PluginMetadata) { 71 | fake.getMetadataMutex.Lock() 72 | defer fake.getMetadataMutex.Unlock() 73 | fake.GetMetadataStub = nil 74 | if fake.getMetadataReturnsOnCall == nil { 75 | fake.getMetadataReturnsOnCall = make(map[int]struct { 76 | result1 plugin.PluginMetadata 77 | }) 78 | } 79 | fake.getMetadataReturnsOnCall[i] = struct { 80 | result1 plugin.PluginMetadata 81 | }{result1} 82 | } 83 | 84 | func (fake *FakePlugin) Run(arg1 plugin.PluginContext, arg2 []string) { 85 | var arg2Copy []string 86 | if arg2 != nil { 87 | arg2Copy = make([]string, len(arg2)) 88 | copy(arg2Copy, arg2) 89 | } 90 | fake.runMutex.Lock() 91 | fake.runArgsForCall = append(fake.runArgsForCall, struct { 92 | arg1 plugin.PluginContext 93 | arg2 []string 94 | }{arg1, arg2Copy}) 95 | stub := fake.RunStub 96 | fake.recordInvocation("Run", []interface{}{arg1, arg2Copy}) 97 | fake.runMutex.Unlock() 98 | if stub != nil { 99 | fake.RunStub(arg1, arg2) 100 | } 101 | } 102 | 103 | func (fake *FakePlugin) RunCallCount() int { 104 | fake.runMutex.RLock() 105 | defer fake.runMutex.RUnlock() 106 | return len(fake.runArgsForCall) 107 | } 108 | 109 | func (fake *FakePlugin) RunCalls(stub func(plugin.PluginContext, []string)) { 110 | fake.runMutex.Lock() 111 | defer fake.runMutex.Unlock() 112 | fake.RunStub = stub 113 | } 114 | 115 | func (fake *FakePlugin) RunArgsForCall(i int) (plugin.PluginContext, []string) { 116 | fake.runMutex.RLock() 117 | defer fake.runMutex.RUnlock() 118 | argsForCall := fake.runArgsForCall[i] 119 | return argsForCall.arg1, argsForCall.arg2 120 | } 121 | 122 | func (fake *FakePlugin) Invocations() map[string][][]interface{} { 123 | fake.invocationsMutex.RLock() 124 | defer fake.invocationsMutex.RUnlock() 125 | fake.getMetadataMutex.RLock() 126 | defer fake.getMetadataMutex.RUnlock() 127 | fake.runMutex.RLock() 128 | defer fake.runMutex.RUnlock() 129 | copiedInvocations := map[string][][]interface{}{} 130 | for key, value := range fake.invocations { 131 | copiedInvocations[key] = value 132 | } 133 | return copiedInvocations 134 | } 135 | 136 | func (fake *FakePlugin) recordInvocation(key string, args []interface{}) { 137 | fake.invocationsMutex.Lock() 138 | defer fake.invocationsMutex.Unlock() 139 | if fake.invocations == nil { 140 | fake.invocations = map[string][][]interface{}{} 141 | } 142 | if fake.invocations[key] == nil { 143 | fake.invocations[key] = [][]interface{}{} 144 | } 145 | fake.invocations[key] = append(fake.invocations[key], args) 146 | } 147 | 148 | var _ plugin.Plugin = new(FakePlugin) 149 | -------------------------------------------------------------------------------- /testhelpers/command.go: -------------------------------------------------------------------------------- 1 | package testhelpers 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // GenerateCobraCommand will create a cobra command with basic flags used for testing 12 | func GenerateCobraCommand() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "example", 15 | } 16 | 17 | cmd.Flags().StringP("output", "", "", "Specify output format, only 'JSON' is supported.") 18 | cmd.Flags().BoolP("quiet", "q", false, "Suppress verbose output") 19 | cmd.Flags().BoolP("outputJSON", "", false, "Output data into JSON format") 20 | 21 | // NOTE: Added #nosec tag since flag is not attached to a real command 22 | cmd.Flags().MarkDeprecated("outputJSON", "outputJSON deprecated use --output instead") // #nosec 23 | return cmd 24 | } 25 | 26 | type mockStdoutFile struct { 27 | File *os.File 28 | } 29 | 30 | // CreateMockStdout will create a temp file used for mocking stdout for testing 31 | func CreateMockStdout() *mockStdoutFile { 32 | f, err := os.CreateTemp("", "cli_sdk_mock_stdout") 33 | if err != nil { 34 | panic(fmt.Errorf("failed to create tmp file for mocking stdout: %v", err.Error())) 35 | } 36 | return &mockStdoutFile{ 37 | File: f, 38 | } 39 | } 40 | 41 | // Read will open the temp mock stdout file and return contents as a string 42 | func (m *mockStdoutFile) Read() string { 43 | out, err := ioutil.ReadFile(m.File.Name()) 44 | if err != nil { 45 | panic(fmt.Errorf("failed to read stdout file: %v", err.Error())) 46 | } 47 | return string(out) 48 | } 49 | -------------------------------------------------------------------------------- /testhelpers/configuration/test_config.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/configuration" 5 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/configuration/core_config" 6 | ) 7 | 8 | type FakePersistor struct{} 9 | 10 | func (f *FakePersistor) Save(configuration.DataInterface) error { return nil } 11 | func (f *FakePersistor) Load(configuration.DataInterface) error { return nil } 12 | func (f *FakePersistor) Exists() bool { return true } 13 | 14 | func NewFakeCoreConfig() core_config.ReadWriter { 15 | config := core_config.NewCoreConfigFromPersistor(new(FakePersistor), func(err error) { panic(err) }) 16 | return config 17 | } 18 | -------------------------------------------------------------------------------- /testhelpers/matchers/contain_substrings.go: -------------------------------------------------------------------------------- 1 | package matchers 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 8 | "github.com/onsi/gomega" 9 | ) 10 | 11 | type SliceMatcher struct { 12 | expected [][]string 13 | failedAtIndex int 14 | } 15 | 16 | func ContainSubstrings(substrings ...[]string) gomega.OmegaMatcher { 17 | return &SliceMatcher{expected: substrings} 18 | } 19 | 20 | func (matcher *SliceMatcher) Match(actual interface{}) (success bool, err error) { 21 | actualString, ok := actual.(string) 22 | if !ok { 23 | return false, fmt.Errorf("ContainSubstrings matcher expects a string, but it's actually a %T", actual) 24 | } 25 | 26 | actualStrings := strings.Split(actualString, "\n") 27 | allStringsMatched := make([]bool, len(matcher.expected)) 28 | 29 | for index, expectedArray := range matcher.expected { 30 | for _, actualValue := range actualStrings { 31 | actualValue = terminal.Decolorize(actualValue) 32 | 33 | allStringsFound := true 34 | 35 | for _, expectedValue := range expectedArray { 36 | if !strings.Contains(actualValue, expectedValue) { 37 | allStringsFound = false 38 | } 39 | } 40 | 41 | if allStringsFound { 42 | allStringsMatched[index] = true 43 | break 44 | } 45 | } 46 | } 47 | 48 | for index, value := range allStringsMatched { 49 | if !value { 50 | matcher.failedAtIndex = index 51 | return false, nil 52 | } 53 | } 54 | 55 | return true, nil 56 | } 57 | 58 | func (matcher *SliceMatcher) FailureMessage(actual interface{}) string { 59 | return fmt.Sprintf("expected to find \"%s\" in actual:\n\"%v\"\n", matcher.expected[matcher.failedAtIndex], actual) 60 | } 61 | 62 | func (matcher *SliceMatcher) NegatedFailureMessage(actual interface{}) string { 63 | return fmt.Sprintf("expected to not find \"%s\" in actual:\n\"%v\"\n", matcher.expected[matcher.failedAtIndex], actual) 64 | } 65 | -------------------------------------------------------------------------------- /testhelpers/terminal/test_ui.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | term "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 11 | ) 12 | 13 | type choicesPrompt struct { 14 | Message string 15 | Choices []string 16 | } 17 | 18 | func ChoicesPrompt(message string, choices ...string) choicesPrompt { 19 | return choicesPrompt{ 20 | Message: message, 21 | Choices: choices, 22 | } 23 | } 24 | 25 | type FakeUI struct { 26 | Prompts []string 27 | PasswordPrompts []string 28 | ChoicesPrompts []choicesPrompt 29 | WarnOutputs []string 30 | stdoutWriter io.Writer 31 | stdErrWriter io.Writer 32 | stdInWriter io.Reader 33 | 34 | inputs bytes.Buffer 35 | stdOut bytes.Buffer 36 | stdErr bytes.Buffer 37 | 38 | quiet bool 39 | } 40 | 41 | func NewFakeUI() *FakeUI { 42 | // NOTE: when mocking the UI we would like to have a large 43 | /// terminal width to start with 44 | os.Setenv("TEST_TERMINAL_WIDTH", "300") 45 | return &FakeUI{} 46 | } 47 | 48 | func (ui *FakeUI) Say(template string, args ...interface{}) { 49 | ui.Print(template, args...) 50 | } 51 | 52 | func (ui *FakeUI) Verbose(template string, args ...interface{}) { 53 | if ui.quiet { 54 | return 55 | } 56 | ui.Print(template, args...) 57 | } 58 | 59 | func (ui *FakeUI) Print(template string, args ...interface{}) { 60 | message := fmt.Sprintf(template, args...) 61 | fmt.Fprintln(&ui.stdOut, message) 62 | } 63 | 64 | func (ui *FakeUI) error(template string, args ...interface{}) { 65 | message := fmt.Sprintf(template, args...) 66 | fmt.Fprintln(&ui.stdErr, message) 67 | } 68 | 69 | func (ui *FakeUI) Ok() { 70 | if ui.quiet { 71 | return 72 | } 73 | ui.Say(term.SuccessColor("OK")) 74 | } 75 | 76 | func (ui *FakeUI) Info(template string, args ...interface{}) { 77 | if ui.quiet { 78 | return 79 | } 80 | ui.error(template, args...) 81 | } 82 | 83 | func (ui *FakeUI) Failed(template string, args ...interface{}) { 84 | message := fmt.Sprintf(template, args...) 85 | ui.Info(term.FailureColor("FAILED")) 86 | ui.error(message) 87 | ui.Info("") 88 | } 89 | 90 | func (ui *FakeUI) Warn(template string, args ...interface{}) { 91 | if ui.quiet { 92 | return 93 | } 94 | 95 | message := fmt.Sprintf(template, args...) 96 | ui.WarnOutputs = append(ui.WarnOutputs, message) 97 | 98 | ui.error(template, args...) 99 | } 100 | 101 | func (ui *FakeUI) Prompt(message string, options *term.PromptOptions) *term.Prompt { 102 | if options.HideInput { 103 | ui.PasswordPrompts = append(ui.PasswordPrompts, message) 104 | } else { 105 | ui.Prompts = append(ui.Prompts, message) 106 | } 107 | 108 | if ui.inputs.Len() == 0 { 109 | panic("No input provided to Fake UI for prompt: " + message) 110 | } 111 | 112 | p := term.NewPrompt(message, options) 113 | p.Reader = &ui.inputs 114 | p.Writer = &ui.stdOut 115 | return p 116 | } 117 | 118 | func (ui *FakeUI) ChoicesPrompt(message string, choices []string, options *term.PromptOptions) *term.Prompt { 119 | ui.ChoicesPrompts = append(ui.ChoicesPrompts, ChoicesPrompt(message, choices...)) 120 | 121 | if ui.inputs.Len() == 0 { 122 | panic(fmt.Sprintf("No input provided to Fake UI for choices prompt: %s [%s]", 123 | message, strings.Join(choices, ", "))) 124 | } 125 | 126 | p := term.NewChoicesPrompt(message, choices, options) 127 | p.Reader = &ui.inputs 128 | p.Writer = &ui.stdOut 129 | return p 130 | } 131 | 132 | func (ui *FakeUI) Ask(template string, args ...interface{}) (string, error) { 133 | message := fmt.Sprintf(template, args...) 134 | 135 | var answer string 136 | err := ui.Prompt(message, 137 | &term.PromptOptions{ 138 | HideDefault: true, 139 | NoLoop: true, 140 | }, 141 | ).Resolve(&answer) 142 | 143 | return answer, err 144 | } 145 | 146 | func (ui *FakeUI) AskForPassword(template string, args ...interface{}) (string, error) { 147 | message := fmt.Sprintf(template, args...) 148 | 149 | var passwd string 150 | err := ui.Prompt( 151 | message, 152 | &term.PromptOptions{ 153 | HideDefault: true, 154 | HideInput: true, 155 | NoLoop: true, 156 | }, 157 | ).Resolve(&passwd) 158 | 159 | return passwd, err 160 | } 161 | 162 | func (ui *FakeUI) Confirm(template string, args ...interface{}) (bool, error) { 163 | return ui.ConfirmWithDefault(false, template, args...) 164 | } 165 | 166 | func (ui *FakeUI) ConfirmWithDefault(defaultBool bool, template string, args ...interface{}) (bool, error) { 167 | message := fmt.Sprintf(template, args...) 168 | 169 | var yn = defaultBool 170 | err := ui.Prompt( 171 | message, 172 | &term.PromptOptions{ 173 | HideDefault: true, 174 | NoLoop: true, 175 | }, 176 | ).Resolve(&yn) 177 | return yn, err 178 | } 179 | 180 | func (ui *FakeUI) SelectOne(choices []string, template string, args ...interface{}) (int, error) { 181 | message := fmt.Sprintf(template, args...) 182 | 183 | var selected string 184 | err := ui.ChoicesPrompt( 185 | message, 186 | choices, 187 | &term.PromptOptions{ 188 | HideDefault: true, 189 | }, 190 | ).Resolve(&selected) 191 | 192 | if err != nil { 193 | return -1, err 194 | } 195 | 196 | for i, c := range choices { 197 | if selected == c { 198 | return i, nil 199 | } 200 | } 201 | 202 | return -1, nil 203 | } 204 | 205 | func (ui *FakeUI) Table(headers []string) term.Table { 206 | return term.NewTable(&ui.stdOut, headers) 207 | } 208 | 209 | func (ui *FakeUI) Inputs(lines ...string) { 210 | for _, line := range lines { 211 | ui.inputs.WriteString(line + "\n") 212 | } 213 | } 214 | 215 | func (ui *FakeUI) Outputs() string { 216 | return ui.stdOut.String() 217 | } 218 | 219 | func (ui *FakeUI) Errors() string { 220 | return ui.stdErr.String() 221 | } 222 | 223 | func (ui *FakeUI) Writer() io.Writer { 224 | return &ui.stdOut 225 | } 226 | 227 | func (ui *FakeUI) ErrWriter() io.Writer { 228 | return &ui.stdErr 229 | } 230 | 231 | // NOTE: SetErrWriter is added here since the method is part of the UI type Interface interface 232 | // the method is not needed for testing 233 | func (ui *FakeUI) SetErrWriter(buf io.Writer) { 234 | panic("unimplemented") 235 | } 236 | 237 | // NOTE: SetErrWriter is added here since the method is part of the UI type Interface interface 238 | // the method is not needed for testing 239 | func (ui *FakeUI) SetWriter(buf io.Writer) { 240 | panic("unimplemented") 241 | } 242 | 243 | func (ui *FakeUI) SetQuiet(quiet bool) { 244 | ui.quiet = quiet 245 | } 246 | 247 | func (ui *FakeUI) Quiet() bool { 248 | return ui.quiet 249 | } 250 | -------------------------------------------------------------------------------- /utils/Readme.md: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | We assume that the following tools are installed: 3 | 4 | - [Go](https://golang.org/) 5 | - [go-bindata](https://github.com/jteeuwen/go-bindata) 6 | - [i18n4go](https://github.com/maximilien/i18n4go) 7 | - [cucumber](https://github.com/cucumber/cucumber) 8 | - [aruba](https://github.com/cucumber/aruba) 9 | 10 | # How to create plugin project 11 | 12 | 1. Create the project folder 13 | 2. Copy `setup-project.sh` and `new-command.sh` under the project root folder 14 | 3. Compose `project.properties` under the project root folder 15 | 4. Run `./setup-project.sh` 16 | 17 | ## setup-project.sh 18 | **Syntax** 19 | ```setup-project.sh [-travis] [-h]``` 20 | **Flags:** 21 | 22 | - -travis: create a Travis build file 23 | - -h: show help 24 | 25 | ## project.properties 26 | To generate the project, you need to provide a `project.properties` file with the following contents. 27 | ```properties 28 | # name of the plugin, will be shown in base CLI when `bx plugin list` 29 | plugin_name=test-plugin 30 | 31 | # base name of the plugin binary files, i.e. ${base_name}-${os}-${arch} 32 | plugin_file_basename=test-plugin 33 | 34 | # major namespace used in this plugin, this will be used as the namespace of "hello" command 35 | # You can always change it later 36 | default_namespace=catalog 37 | 38 | # new namespaces used by this plugin 39 | #new_namespaces=("test1" "test2") 40 | 41 | # Go version, if not specified, then deduce from the current context 42 | #go_version=1.8.1 43 | 44 | # go package path, please align with the project 45 | project_path=github.ibm.com/Bluemix/test-plugin 46 | ``` 47 | 48 | # How to add a new command 49 | Run `new-command.sh` 50 | **Syntax** 51 | ```new-command.sh NAME [-d DESCRIPTION] [-u USAGE] [-n NAMESPACE] [-p PACKAGE] [-h]``` 52 | 53 | **Flags** 54 | 55 | - -d DESCRIPTION: description of the command 56 | - -u USAGE: usage of the command, e.g. command syntax 57 | - -n NAMESPACE: namespace of the command 58 | - -p PACKAGE: package of the command **under the project**, i.e. exclude the domain of the project. 59 | - -h: show help 60 | -------------------------------------------------------------------------------- /utils/new-command.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ./project.properties 6 | 7 | namespace="${project_namespace}" 8 | description="TODO" 9 | usage="TODO" 10 | name="" 11 | command_package="plugin/commands" 12 | while [[ $# -gt 0 ]] 13 | do 14 | key="$1" 15 | 16 | case $key in 17 | -n) 18 | namespace="$2" 19 | shift 20 | ;; 21 | -d) 22 | description="$2" 23 | shift 24 | ;; 25 | -u) 26 | usage="$2" 27 | shift 28 | ;; 29 | -h) 30 | echo "Usage: new_command.sh COMMAND_NAME [-n Namespace] [-d DESCRIPTIN] [-u USAGE] [-p COMMAND_PACKAGE] -h" 31 | exit 0 32 | ;; 33 | -p) 34 | command_package="$2" 35 | shift 36 | ;; 37 | *) 38 | name="$key" 39 | ;; 40 | esac 41 | shift 42 | done 43 | if [[ -z "${name}" ]]; then 44 | echo "Please specify the command name" 45 | echo "Usage: new_command.sh COMMAND_NAME [-n Namespace] [-d DESCRIPTIN] [-u USAGE] -h" 46 | exit 1 47 | fi 48 | 49 | 50 | IFS="-", read -r -a names <<< "${name}" 51 | for index in "${!names[@]}" 52 | do 53 | names[$index]="$(tr '[:lower:]' '[:upper:]' <<< ${names[index]:0:1})${names[index]:1}" 54 | done 55 | 56 | readonly command_struct_name=$(printf "%s" "${names[@]}") 57 | readonly package="${command_package##*/}" 58 | mkdir -p ${command_package} 59 | cat < ${command_package}/${name}.go 60 | package ${package} 61 | 62 | import ( 63 | 64 | "github.com/urfave/cli" 65 | 66 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/terminal" 67 | "github.com/IBM-Cloud/ibm-cloud-cli-sdk/plugin" 68 | 69 | "${project_path}/plugin/metadata" 70 | . "${project_path}/plugin/i18n" 71 | ) 72 | 73 | type ${command_struct_name} struct { 74 | ui terminal.UI 75 | context plugin.PluginContext 76 | } 77 | 78 | func (cmd *${command_struct_name}) GetMetadata() metadata.CommandMetadata { 79 | return metadata.CommandMetadata{ 80 | Namespace: "${namespace}", 81 | Name: "${name}", 82 | Description: T("${description}"), 83 | Usage: "${usage}", 84 | } 85 | } 86 | 87 | func (cmd *${command_struct_name}) Setup(ui terminal.UI, context plugin.PluginContext) { 88 | cmd.ui = ui 89 | cmd.context = context 90 | } 91 | 92 | func (cmd *${command_struct_name}) Run(context *cli.Context) error { 93 | return nil 94 | } 95 | ENDGO 96 | 97 | if [ "$(uname)" == "Darwin" ]; then 98 | sed -i "" "/return \[\]metadata.Command{/a\\ 99 | new(${package}.${command_struct_name})," plugin/plugin.go 100 | else 101 | sed -i "/return \[\]metadata.Command{/a\\ 102 | new(${package}.${command_struct_name})," plugin/plugin.go 103 | fi 104 | 105 | 106 | readonly file_import="${project_path}/${command_package}" 107 | if ! grep -q "${file_import}" plugin/plugin.go; then 108 | if [ "$(uname)" == "Darwin" ]; then 109 | sed -i "" "/github\.ibm\.com\/IAM\/test\/plugin\/metadata/a\\ 110 | \"${file_import}\"\\ 111 | " plugin/plugin.go 112 | else 113 | sed -i "/github\.ibm\.com\/IAM\/test\/plugin\/metadata/a\\ 114 | \"${file_import}\"\\ 115 | " plugin/plugin.go 116 | fi 117 | fi 118 | -------------------------------------------------------------------------------- /utils/project.properties: -------------------------------------------------------------------------------- 1 | # name of the plugin, will be shown in base CLI when `bx plugin list` 2 | plugin_name=sample-plugin 3 | 4 | # base name of the plugin binary files, i.e. ${base_name}-${os}-${arch} 5 | plugin_file_basename=sample-plugin 6 | 7 | # new namespaces used by this plugin 8 | #new_namespaces=("test1" "test2") 9 | new_namespaces=("sample") 10 | 11 | # major namespace used in this plugin, this will be used as the namespace of "hello" command 12 | # You can always change it later 13 | default_namespace=sample 14 | 15 | 16 | # Go version, if not specified, then deduce from the current context 17 | #go_version=1.8.1 18 | 19 | # go package path, please align with the project 20 | project_path=github.com/IBM-Cloud/ibm-cloud-cli-sdk/sample 21 | --------------------------------------------------------------------------------