diff --git a/README.md b/README.md index 926d8b97..e7983c9f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Another example: let's say that the execution of the previous command results in ## Set credentials arduino-cloud-cli needs a credentials file containing an Arduino IoT Cloud client ID and its corresponding secret. +Credentials can also include an optional organization ID that specifies the organization to use. You can retrieve these credentials from the [cloud](https://create.arduino.cc/iot/integrations) by creating a new API key. Once you have the credentials, execute the following command and provide them: @@ -53,7 +54,8 @@ The credentials file is supported in two different formats: json and yaml. Use t `$ arduino-cloud-cli credentials init --file-format json` -It is also possible to specify credentials directly in `ARDUINO_CLOUD_CLIENT` and `ARDUINO_CLOUD_SECRET` environment variables. Credentials specified in environment variables have higher priority than the ones specified in credentials files. +It is also possible to specify credentials directly in `ARDUINO_CLOUD_CLIENT`, `ARDUINO_CLOUD_SECRET` and optionally `ARDUINO_CLOUD_ORGANIZATION` environment variables. Credentials specified in environment variables have higher priority than the ones specified in credentials files. +Please note that credentials are correctly extracted from environment variables only if all the mandatory credentials parameters (client and secret) are found in environment variables. (think of it as another config file but with higher priority) #### Find credentials diff --git a/cli/credentials/init.go b/cli/credentials/init.go index f4a6adb6..5f68c9fc 100644 --- a/cli/credentials/init.go +++ b/cli/credentials/init.go @@ -100,7 +100,7 @@ func runInitCommand(flags *initFlags) error { // Take needed credentials starting an interactive mode feedback.Print("To obtain your API credentials visit https://create.arduino.cc/iot/integrations") - id, key, err := paramsPrompt() + id, key, org, err := paramsPrompt() if err != nil { return fmt.Errorf("cannot take credentials params: %w", err) } @@ -110,6 +110,7 @@ func runInitCommand(flags *initFlags) error { newSettings.SetConfigPermissions(os.FileMode(0600)) newSettings.Set("client", id) newSettings.Set("secret", key) + newSettings.Set("organization", org) if err := newSettings.WriteConfigAs(credFile.String()); err != nil { return fmt.Errorf("cannot write credentials file: %w", err) } @@ -118,7 +119,7 @@ func runInitCommand(flags *initFlags) error { return nil } -func paramsPrompt() (id, key string, err error) { +func paramsPrompt() (id, key, org string, err error) { prompt := promptui.Prompt{ Label: "Please enter the Client ID", Validate: func(s string) error { @@ -130,7 +131,7 @@ func paramsPrompt() (id, key string, err error) { } id, err = prompt.Run() if err != nil { - return "", "", fmt.Errorf("client prompt fail: %w", err) + return "", "", "", fmt.Errorf("client prompt fail: %w", err) } prompt = promptui.Prompt{ @@ -145,8 +146,23 @@ func paramsPrompt() (id, key string, err error) { } key, err = prompt.Run() if err != nil { - return "", "", fmt.Errorf("client secret prompt fail: %w", err) + return "", "", "", fmt.Errorf("client secret prompt fail: %w", err) } - return id, key, nil + prompt = promptui.Prompt{ + Mask: '*', + Label: "Please enter the Organization ID - if any - Leave empty otherwise", + Validate: func(s string) error { + if len(s) != 0 && len(s) != config.OrganizationLen { + return errors.New("organization id not valid") + } + return nil + }, + } + org, err = prompt.Run() + if err != nil { + return "", "", "", fmt.Errorf("organization id prompt fail: %w", err) + } + + return id, key, org, nil } diff --git a/command/dashboard/create.go b/command/dashboard/create.go index be733337..cc228b01 100644 --- a/command/dashboard/create.go +++ b/command/dashboard/create.go @@ -34,7 +34,7 @@ type CreateParams struct { // Create allows to create a new dashboard. func Create(params *CreateParams, cred *config.Credentials) (*DashboardInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/dashboard/delete.go b/command/dashboard/delete.go index 60b29526..91679cf5 100644 --- a/command/dashboard/delete.go +++ b/command/dashboard/delete.go @@ -31,7 +31,7 @@ type DeleteParams struct { // Delete command is used to delete a dashboard // from Arduino IoT Cloud. func Delete(params *DeleteParams, cred *config.Credentials) error { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/dashboard/extract.go b/command/dashboard/extract.go index 125702b5..8cd36cec 100644 --- a/command/dashboard/extract.go +++ b/command/dashboard/extract.go @@ -34,7 +34,7 @@ type ExtractParams struct { // Extract command is used to extract a dashboard template // from a dashboard on Arduino IoT Cloud. func Extract(params *ExtractParams, cred *config.Credentials) (map[string]interface{}, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/dashboard/list.go b/command/dashboard/list.go index 4d9cb2e8..354deb5a 100644 --- a/command/dashboard/list.go +++ b/command/dashboard/list.go @@ -25,7 +25,7 @@ import ( // List command is used to list // the dashboards of Arduino IoT Cloud. func List(cred *config.Credentials) ([]DashboardInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/device/create.go b/command/device/create.go index 3c8ebefb..d92c0091 100644 --- a/command/device/create.go +++ b/command/device/create.go @@ -63,7 +63,7 @@ func Create(params *CreateParams, cred *config.Credentials) (*DeviceInfo, error) ) } - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/device/creategeneric.go b/command/device/creategeneric.go index e0b3b08b..51b88bb9 100644 --- a/command/device/creategeneric.go +++ b/command/device/creategeneric.go @@ -44,7 +44,7 @@ type DeviceGenericInfo struct { // CreateGeneric command is used to add a new generic device to Arduino IoT Cloud. func CreateGeneric(params *CreateGenericParams, cred *config.Credentials) (*DeviceGenericInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/device/createlora.go b/command/device/createlora.go index 3a0c3e36..642c0f3a 100644 --- a/command/device/createlora.go +++ b/command/device/createlora.go @@ -107,7 +107,7 @@ func CreateLora(params *CreateLoraParams, cred *config.Credentials) (*DeviceLora return nil, err } - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/device/delete.go b/command/device/delete.go index 6e84372b..ae99d998 100644 --- a/command/device/delete.go +++ b/command/device/delete.go @@ -43,7 +43,7 @@ func Delete(params *DeleteParams, cred *config.Credentials) error { return errors.New("cannot use both ID and Tags. only one of them should be not nil") } - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/device/list.go b/command/device/list.go index 85d82dbd..f5f7f93b 100644 --- a/command/device/list.go +++ b/command/device/list.go @@ -33,7 +33,7 @@ type ListParams struct { // List command is used to list // the devices of Arduino IoT Cloud. func List(params *ListParams, cred *config.Credentials) ([]DeviceInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/device/listfrequency.go b/command/device/listfrequency.go index 88ed3a9f..633e6067 100644 --- a/command/device/listfrequency.go +++ b/command/device/listfrequency.go @@ -34,7 +34,7 @@ type FrequencyPlanInfo struct { // ListFrequencyPlans command is used to list // the supported LoRa frequency plans. func ListFrequencyPlans(cred *config.Credentials) ([]FrequencyPlanInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/ota/massupload.go b/command/ota/massupload.go index 3889ffc3..8bf6bc93 100644 --- a/command/ota/massupload.go +++ b/command/ota/massupload.go @@ -71,7 +71,7 @@ func MassUpload(params *MassUploadParams, cred *config.Credentials) ([]Result, e return nil, fmt.Errorf("%s: %w", "cannot generate .ota file", err) } - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/ota/upload.go b/command/ota/upload.go index 71cd6ae2..cf3fd288 100644 --- a/command/ota/upload.go +++ b/command/ota/upload.go @@ -45,7 +45,7 @@ type UploadParams struct { // Upload command is used to upload a firmware OTA, // on a device of Arduino IoT Cloud. func Upload(params *UploadParams, cred *config.Credentials) error { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/tag/create.go b/command/tag/create.go index 2317e33e..82eb89ef 100644 --- a/command/tag/create.go +++ b/command/tag/create.go @@ -35,7 +35,7 @@ type CreateTagsParams struct { // CreateTags allows to create or overwrite tags // on a resource of Arduino IoT Cloud. func CreateTags(params *CreateTagsParams, cred *config.Credentials) error { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/tag/delete.go b/command/tag/delete.go index b0a9d02d..6b8738cb 100644 --- a/command/tag/delete.go +++ b/command/tag/delete.go @@ -35,7 +35,7 @@ type DeleteTagsParams struct { // DeleteTags command is used to delete tags of a device // from Arduino IoT Cloud. func DeleteTags(params *DeleteTagsParams, cred *config.Credentials) error { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/thing/bind.go b/command/thing/bind.go index 5dfe6336..6d54e730 100644 --- a/command/thing/bind.go +++ b/command/thing/bind.go @@ -33,7 +33,7 @@ type BindParams struct { // Bind command is used to bind a thing to a device // on Arduino IoT Cloud. func Bind(params *BindParams, cred *config.Credentials) error { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/thing/clone.go b/command/thing/clone.go index c22cc564..cb6cb80a 100644 --- a/command/thing/clone.go +++ b/command/thing/clone.go @@ -33,7 +33,7 @@ type CloneParams struct { // Clone allows to create a new thing from an already existing one. func Clone(params *CloneParams, cred *config.Credentials) (*ThingInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/thing/create.go b/command/thing/create.go index 9ffd2eb3..1a25f3c4 100644 --- a/command/thing/create.go +++ b/command/thing/create.go @@ -34,7 +34,7 @@ type CreateParams struct { // Create allows to create a new thing. func Create(params *CreateParams, cred *config.Credentials) (*ThingInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/thing/delete.go b/command/thing/delete.go index 80e84feb..6e1662db 100644 --- a/command/thing/delete.go +++ b/command/thing/delete.go @@ -43,7 +43,7 @@ func Delete(params *DeleteParams, cred *config.Credentials) error { return errors.New("cannot use both ID and Tags. only one of them should be not nil") } - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return err } diff --git a/command/thing/extract.go b/command/thing/extract.go index a851f0eb..c05280a3 100644 --- a/command/thing/extract.go +++ b/command/thing/extract.go @@ -34,7 +34,7 @@ type ExtractParams struct { // Extract command is used to extract a thing template // from a thing on Arduino IoT Cloud. func Extract(params *ExtractParams, cred *config.Credentials) (map[string]interface{}, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/command/thing/list.go b/command/thing/list.go index 2ecce66a..d81f6639 100644 --- a/command/thing/list.go +++ b/command/thing/list.go @@ -36,7 +36,7 @@ type ListParams struct { // List command is used to list // the things of Arduino IoT Cloud. func List(params *ListParams, cred *config.Credentials) ([]ThingInfo, error) { - iotClient, err := iot.NewClient(cred.Client, cred.Secret) + iotClient, err := iot.NewClient(cred) if err != nil { return nil, err } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index e6a71cc7..a650d61c 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -31,6 +31,8 @@ const ( ClientIDLen = 32 // ClientSecretLen specifies the length of Arduino IoT Cloud client secrets. ClientSecretLen = 64 + // OrganizationLen specifies the length of Arduino IoT Cloud organization. + OrganizationLen = 36 // EnvPrefix is the prefix environment variables should have to be // fetched as credentials parameters during the credentials retrieval. @@ -46,12 +48,15 @@ func SetEmptyCredentials(settings *viper.Viper) { settings.SetDefault("client", "") // Secret settings.SetDefault("secret", "") + // OrganizationID + settings.SetDefault("organization", "") } // Credentials contains the parameters of Arduino IoT Cloud credentials. type Credentials struct { - Client string `mapstructure:"client"` // Client ID of the user - Secret string `mapstructure:"secret"` // Secret ID of the user, unique for each Client ID + Client string `mapstructure:"client"` // Client ID of the user; mandatory. + Secret string `mapstructure:"secret"` // Secret ID of the user, unique for each Client ID; mandatory. + Organization string `mapstructure:"organization"` // Organization ID of the user; this is considered optional. } // Validate the credentials. @@ -71,11 +76,19 @@ func (c *Credentials) Validate() error { len(c.Secret), ) } + if len(c.Organization) != 0 && len(c.Organization) != OrganizationLen { + return fmt.Errorf( + "organization not valid, expected len %d but got %d", + OrganizationLen, + len(c.Organization), + ) + } return nil } -// IsEmpty checks if credentials has no params set. -func (c *Credentials) IsEmpty() bool { +// Complete checks if Credentials has all the mandatory params set. +// Optional parameters are not considered here. +func (c *Credentials) Complete() bool { return len(c.Client) == 0 && len(c.Secret) == 0 } @@ -91,7 +104,7 @@ func FindCredentials() (source string, err error) { if err != nil { return "", fmt.Errorf("looking for credentials in environment variables: %w", err) } - if !c.IsEmpty() { + if c.Complete() { logrus.Infof("Credentials found in environment variables with prefix '%s'", EnvPrefix) return "environment variables", nil } @@ -123,7 +136,7 @@ func RetrieveCredentials() (cred *Credentials, err error) { return nil, fmt.Errorf("reading credentials from environment variables: %w", err) } // Returns credentials if found in env - if !cred.IsEmpty() { + if !cred.Complete() { // Returns error if credentials are found but are not valid if err := cred.Validate(); err != nil { return nil, fmt.Errorf( diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 41b3d1a0..9250ba54 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -28,12 +28,15 @@ import ( func TestRetrieveCredentials(t *testing.T) { var ( - validSecret = "qaRZGEbnQNNvmaeTLqy8Bxs22wLZ6H7obIiNSveTLPdoQuylANnuy6WBOw16XoqH" - validClient = "CQ4iZ5sebOfhGRwUn3IV0r1YFMNrMTIx" - validConfig = &Credentials{validClient, validSecret} - invalidConfig = &Credentials{"", validSecret} - clientEnv = EnvPrefix + "_CLIENT" - secretEnv = EnvPrefix + "_SECRET" + validSecret = "qaRZGEbnQNNvmaeTLqy8Bxs22wLZ6H7obIiNSveTLPdoQuylANnuy6WBOw16XoqH" + validClient = "CQ4iZ5sebOfhGRwUn3IV0r1YFMNrMTIx" + validOrganization = "dc6a6159-3cd5-41a2-b391-553b1351cd98" + validConfig = &Credentials{Client: validClient, Secret: validSecret} + validWithOptionalConfig = &Credentials{Client: validClient, Secret: validSecret, Organization: validOrganization} + invalidConfig = &Credentials{Client: "", Secret: validSecret} + clientEnv = EnvPrefix + "_CLIENT" + secretEnv = EnvPrefix + "_SECRET" + organizationEnv = EnvPrefix + "_ORGANIZATION" ) tests := []struct { @@ -44,7 +47,7 @@ func TestRetrieveCredentials(t *testing.T) { wantedErr bool }{ { - name: "valid credentials written in env", + name: "valid credentials with only mandatory params written in env", pre: func() { os.Setenv(clientEnv, validConfig.Client) os.Setenv(secretEnv, validConfig.Secret) @@ -57,6 +60,22 @@ func TestRetrieveCredentials(t *testing.T) { wantedErr: false, }, + { + name: "valid credentials with optional params written in env", + pre: func() { + os.Setenv(clientEnv, validWithOptionalConfig.Client) + os.Setenv(secretEnv, validWithOptionalConfig.Secret) + os.Setenv(organizationEnv, validWithOptionalConfig.Organization) + }, + post: func() { + os.Unsetenv(clientEnv) + os.Unsetenv(secretEnv) + os.Unsetenv(organizationEnv) + }, + wantedConfig: validWithOptionalConfig, + wantedErr: false, + }, + { name: "invalid credentials written in env", pre: func() { @@ -92,6 +111,27 @@ func TestRetrieveCredentials(t *testing.T) { wantedErr: false, }, + { + name: "valid credentials with optional params written in parent of cwd", + pre: func() { + parent := "test-parent" + cwd := "test-parent/test-cwd" + os.MkdirAll(cwd, os.FileMode(0777)) + // Write valid credentials in parent dir + os.Chdir(parent) + b, _ := json.Marshal(validWithOptionalConfig) + os.WriteFile(CredentialsFilename+".json", b, os.FileMode(0777)) + // Cwd has no credentials file + os.Chdir("test-cwd") + }, + post: func() { + os.Chdir("../..") + os.RemoveAll("test-parent") + }, + wantedConfig: validWithOptionalConfig, + wantedErr: false, + }, + { name: "invalid credentials written in cwd, ignore credentials of parent dir", pre: func() { @@ -161,8 +201,9 @@ func TestRetrieveCredentials(t *testing.T) { func TestValidate(t *testing.T) { var ( - validSecret = "qaRZGEbnQNNvmaeTLqy8Bxs22wLZ6H7obIiNSveTLPdoQuylANnuy6WBOw16XoqH" - validClient = "CQ4iZ5sebOfhGRwUn3IV0r1YFMNrMTIx" + validSecret = "qaRZGEbnQNNvmaeTLqy8Bxs22wLZ6H7obIiNSveTLPdoQuylANnuy6WBOw16XoqH" + validClient = "CQ4iZ5sebOfhGRwUn3IV0r1YFMNrMTIx" + validOrganization = "dc6a6159-3cd5-41a2-b391-553b1351cd98" ) tests := []struct { name string @@ -170,12 +211,14 @@ func TestValidate(t *testing.T) { valid bool }{ { - name: "valid credentials", - config: &Credentials{ - Client: validClient, - Secret: validSecret, - }, - valid: true, + name: "valid credentials", + config: &Credentials{Client: validClient, Secret: validSecret, Organization: validOrganization}, + valid: true, + }, + { + name: "valid credentials, organization is optional", + config: &Credentials{Client: validClient, Secret: validSecret, Organization: ""}, + valid: true, }, { name: "invalid client id", @@ -209,10 +252,11 @@ func TestValidate(t *testing.T) { } } -func TestIsEmpty(t *testing.T) { +func TestComplete(t *testing.T) { var ( - validSecret = "qaRZGEbnQNNvmaeTLqy8Bxs22wLZ6H7obIiNSveTLPdoQuylANnuy6WBOw16XoqH" - validClient = "CQ4iZ5sebOfhGRwUn3IV0r1YFMNrMTIx" + validSecret = "qaRZGEbnQNNvmaeTLqy8Bxs22wLZ6H7obIiNSveTLPdoQuylANnuy6WBOw16XoqH" + validClient = "CQ4iZ5sebOfhGRwUn3IV0r1YFMNrMTIx" + validOrganization = "dc6a6159-3cd5-41a2-b391-553b1351cd98" ) tests := []struct { name string @@ -221,7 +265,12 @@ func TestIsEmpty(t *testing.T) { }{ { name: "empty credentials", - config: &Credentials{Client: "", Secret: ""}, + config: &Credentials{Client: "", Secret: "", Organization: ""}, + want: true, + }, + { + name: "empty mandatory credentials - optionals given", + config: &Credentials{Client: "", Secret: "", Organization: validOrganization}, want: true, }, { @@ -238,7 +287,7 @@ func TestIsEmpty(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.config.IsEmpty() + got := tt.config.Complete() if got != tt.want { t.Errorf("Expected %v but got %v, with credentials: %v", tt.want, got, tt.config) } diff --git a/internal/iot/client.go b/internal/iot/client.go index cab6f7ed..5a3e6df9 100644 --- a/internal/iot/client.go +++ b/internal/iot/client.go @@ -23,6 +23,7 @@ import ( "os" "github.com/antihax/optional" + "github.com/arduino/arduino-cloud-cli/internal/config" iotclient "github.com/arduino/iot-client-go" ) @@ -33,10 +34,10 @@ type Client struct { } // NewClient returns a new client implementing the Client interface. -// It needs a ClientID and SecretID for cloud authentication. -func NewClient(clientID, secretID string) (*Client, error) { +// It needs client Credentials for cloud authentication. +func NewClient(cred *config.Credentials) (*Client, error) { cl := &Client{} - err := cl.setup(clientID, secretID) + err := cl.setup(cred.Client, cred.Secret, cred.Organization) if err != nil { err = fmt.Errorf("instantiate new iot client: %w", err) return nil, err @@ -350,7 +351,7 @@ func (cl *Client) DashboardDelete(id string) error { return nil } -func (cl *Client) setup(client, secret string) error { +func (cl *Client) setup(client, secret, organization string) error { // Get the access token in exchange of client_id and client_secret tok, err := token(client, secret) if err != nil { @@ -363,7 +364,11 @@ func (cl *Client) setup(client, secret string) error { // Create an instance of the iot-api Go client, we pass an empty config // because defaults are ok - cl.api = iotclient.NewAPIClient(iotclient.NewConfiguration()) + config := iotclient.NewConfiguration() + if organization != "" { + config.DefaultHeader = map[string]string{"X-Organization": organization} + } + cl.api = iotclient.NewAPIClient(config) return nil }