diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b7fe0538e..03d50f94b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,6 +68,11 @@ jobs: - "pkg/sdk/**" - "pkg/events/**" + cli: + - "cli/**" + - "cmd/cli/**" + - "pkg/sdk/**" + consumers: - "consumers/**" - "cmd/postgres-writer/**" @@ -109,6 +114,11 @@ jobs: run: | go test --race -v -count=1 -coverprofile=coverage/bootstrap.out ./bootstrap/... + - name: Run cli tests + if: steps.changes.outputs.cli == 'true' || steps.changes.outputs.workflow == 'true' + run: | + go test --race -v -count=1 -coverprofile=coverage/cli.out ./cli/... + - name: Run consumers tests if: steps.changes.outputs.consumers == 'true' || steps.changes.outputs.workflow == 'true' run: | diff --git a/Makefile b/Makefile index db30d18f1..9a5c5b140 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ MG_DOCKER_IMAGE_NAME_PREFIX ?= ghcr.io/absmach/magistrala BUILD_DIR = build -SERVICES = bootstrap provision re postgres-writer postgres-reader timescale-writer timescale-reader +SERVICES = bootstrap provision re postgres-writer postgres-reader timescale-writer timescale-reader cli DOCKERS = $(addprefix docker_,$(SERVICES)) DOCKERS_DEV = $(addprefix docker_dev_,$(SERVICES)) CGO_ENABLED ?= 0 diff --git a/cli/bootstrap.go b/cli/bootstrap.go new file mode 100644 index 000000000..69daac7a6 --- /dev/null +++ b/cli/bootstrap.go @@ -0,0 +1,216 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "encoding/json" + + mgsdk "github.com/absmach/magistrala/pkg/sdk" + "github.com/spf13/cobra" +) + +var cmdBootstrap = []cobra.Command{ + { + Use: "create ", + Short: "Create config", + Long: `Create new Client Bootstrap Config to the user identified by the provided key`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + var cfg mgsdk.BootstrapConfig + if err := json.Unmarshal([]byte(args[0]), &cfg); err != nil { + logErrorCmd(*cmd, err) + return + } + + id, err := sdk.AddBootstrap(cfg, args[1], args[2]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logCreatedCmd(*cmd, id) + }, + }, + { + Use: "get [all | ] ", + Short: "Get config", + Long: `Get Client Config with given ID belonging to the user identified by the given key. + all - lists all config + - view config of `, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + pageMetadata := mgsdk.PageMetadata{ + Offset: Offset, + Limit: Limit, + State: State, + Name: Name, + } + if args[0] == "all" { + l, err := sdk.Bootstraps(pageMetadata, args[1], args[2]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + logJSONCmd(*cmd, l) + return + } + + c, err := sdk.ViewBootstrap(args[0], args[1], args[2]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, c) + }, + }, + { + Use: "update [config | connection | certs ] ", + Short: "Update config", + Long: `Updates editable fields of the provided Config. + config - Updates editable fields of the provided Config. + connection - Updates connections performs update of the channel list corresponding Client is connected to. + channel_ids - '["channel_id1", ...]' + certs - Update bootstrap config certificates.`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 4 { + logUsageCmd(*cmd, cmd.Use) + return + } + if args[0] == "config" { + var cfg mgsdk.BootstrapConfig + if err := json.Unmarshal([]byte(args[1]), &cfg); err != nil { + logErrorCmd(*cmd, err) + return + } + + if err := sdk.UpdateBootstrap(cfg, args[1], args[2]); err != nil { + logErrorCmd(*cmd, err) + return + } + + logOKCmd(*cmd) + return + } + if args[0] == "connection" { + var ids []string + if err := json.Unmarshal([]byte(args[2]), &ids); err != nil { + logErrorCmd(*cmd, err) + return + } + if err := sdk.UpdateBootstrapConnection(args[1], ids, args[3], args[4]); err != nil { + logErrorCmd(*cmd, err) + return + } + + logOKCmd(*cmd) + return + } + if args[0] == "certs" { + cfg, err := sdk.UpdateBootstrapCerts(args[0], args[1], args[2], args[3], args[4], args[5]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, cfg) + return + } + logUsageCmd(*cmd, cmd.Use) + }, + }, + { + Use: "remove ", + Short: "Remove config", + Long: `Removes Config with specified key that belongs to the user identified by the given key`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + if err := sdk.RemoveBootstrap(args[0], args[1], args[2]); err != nil { + logErrorCmd(*cmd, err) + return + } + + logOKCmd(*cmd) + }, + }, + { + Use: "bootstrap [ | secure ]", + Short: "Bootstrap config", + Long: `Returns Config to the Client with provided external ID using external key. + secure - Retrieves a configuration with given external ID and encrypted external key.`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 2 { + logUsageCmd(*cmd, cmd.Use) + return + } + if args[0] == "secure" { + c, err := sdk.BootstrapSecure(args[1], args[2], args[3]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, c) + return + } + c, err := sdk.Bootstrap(args[0], args[1]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, c) + }, + }, + { + Use: "whitelist ", + Short: "Whitelist config", + Long: `Whitelist updates client state config with given id from the authenticated user`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + var cfg mgsdk.BootstrapConfig + if err := json.Unmarshal([]byte(args[0]), &cfg); err != nil { + logErrorCmd(*cmd, err) + return + } + + if err := sdk.Whitelist(cfg.ClientID, cfg.State, args[1], args[2]); err != nil { + logErrorCmd(*cmd, err) + return + } + + logOKCmd(*cmd) + }, + }, +} + +// NewBootstrapCmd returns bootstrap command. +func NewBootstrapCmd() *cobra.Command { + cmd := cobra.Command{ + Use: "bootstrap [create | get | update | remove | bootstrap | whitelist]", + Short: "Bootstrap management", + Long: `Bootstrap management: create, get, update, delete or whitelist Bootstrap config`, + } + + for i := range cmdBootstrap { + cmd.AddCommand(&cmdBootstrap[i]) + } + + return &cmd +} diff --git a/cli/bootstrap_test.go b/cli/bootstrap_test.go new file mode 100644 index 000000000..f549c9b01 --- /dev/null +++ b/cli/bootstrap_test.go @@ -0,0 +1,633 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/absmach/magistrala/cli" + "github.com/absmach/magistrala/internal/testsutil" + mgsdk "github.com/absmach/magistrala/pkg/sdk" + sdkmocks "github.com/absmach/magistrala/pkg/sdk/mocks" + "github.com/absmach/supermq/pkg/errors" + svcerr "github.com/absmach/supermq/pkg/errors/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var ( + clientID = testsutil.GenerateUUID(&testing.T{}) + channelID = testsutil.GenerateUUID(&testing.T{}) + domainID = testsutil.GenerateUUID(&testing.T{}) + bootConfig = mgsdk.BootstrapConfig{ + ClientID: clientID, + Channels: []string{channelID}, + Name: "Test Bootstrap", + ExternalID: "09:6:0:sb:sa", + ExternalKey: "key", + } + validToken = "validToken" + invalidToken = "invalidToken" + extraArg = "extra-arg" + invalidID = "invalidID" + all = "all" +) + +func TestCreateBootstrapConfigCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + bootCmd := cli.NewBootstrapCmd() + rootCmd := setFlags(bootCmd) + + jsonConfig := fmt.Sprintf("{\"external_id\":\"09:6:0:sb:sa\", \"client_id\": \"%s\", \"external_key\":\"key\", \"name\": \"%s\", \"channels\":[\"%s\"]}", clientID, "Test Bootstrap", channelID) + invalidJson := fmt.Sprintf("{\"external_id\":\"09:6:0:sb:sa\", \"client_id\": \"%s\", \"external_key\":\"key\", \"name\": \"%s\", \"channels\":[\"%s\"]", clientID, "Test Bootdtrap", channelID) + cases := []struct { + desc string + args []string + logType outputLog + response string + sdkErr errors.SDKError + errLogMessage string + id string + }{ + { + desc: "create bootstrap config successfully", + args: []string{ + jsonConfig, + domainID, + validToken, + }, + logType: createLog, + id: clientID, + response: fmt.Sprintf("\ncreated: %s\n\n", clientID), + }, + { + desc: "create bootstrap config with invald args", + args: []string{ + jsonConfig, + domainID, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "create bootstrap config with invald json", + args: []string{ + invalidJson, + domainID, + validToken, + }, + sdkErr: errors.NewSDKError(errors.New("unexpected end of JSON input")), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.New("unexpected end of JSON input")), + logType: errLog, + }, + { + desc: "create bootstrap config with invald token", + args: []string{ + jsonConfig, + domainID, + invalidToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("AddBootstrap", mock.Anything, mock.Anything, mock.Anything).Return(tc.id, tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{createCmd}, tc.args...)...) + + switch tc.logType { + case createLog: + assert.Equal(t, tc.response, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.response, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + } + sdkCall.Unset() + }) + } +} + +func TestGetBootstrapConfigCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + bootCmd := cli.NewBootstrapCmd() + rootCmd := setFlags(bootCmd) + + var boot mgsdk.BootstrapConfig + var page mgsdk.BootstrapPage + + cases := []struct { + desc string + args []string + sdkErr errors.SDKError + page mgsdk.BootstrapPage + boot mgsdk.BootstrapConfig + logType outputLog + errLogMessage string + }{ + { + desc: "get all bootstrap config successfully", + args: []string{ + all, + domainID, + validToken, + }, + page: mgsdk.BootstrapPage{ + PageRes: mgsdk.PageRes{ + Total: 1, + Offset: 0, + Limit: 10, + }, + Configs: []mgsdk.BootstrapConfig{bootConfig}, + }, + logType: entityLog, + }, + { + desc: "get bootstrap config with id", + args: []string{ + channelID, + domainID, + validToken, + }, + logType: entityLog, + boot: bootConfig, + }, + { + desc: "get bootstrap config with invalid args", + args: []string{ + all, + domainID, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "get all bootstrap config with invalid token", + args: []string{ + all, + domainID, + invalidToken, + }, + logType: errLog, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + }, + { + desc: "get bootstrap config with invalid id", + args: []string{ + invalidID, + domainID, + validToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("ViewBootstrap", tc.args[0], tc.args[1], tc.args[2]).Return(tc.boot, tc.sdkErr) + sdkCall1 := sdkMock.On("Bootstraps", mock.Anything, tc.args[1], tc.args[2]).Return(tc.page, tc.sdkErr) + + out := executeCommand(t, rootCmd, append([]string{getCmd}, tc.args...)...) + + switch tc.logType { + case entityLog: + if tc.args[0] == all { + err := json.Unmarshal([]byte(out), &page) + assert.Nil(t, err) + assert.Equal(t, tc.page, page, fmt.Sprintf("%v unexpected response, expected: %v, got: %v", tc.desc, tc.page, page)) + } else { + err := json.Unmarshal([]byte(out), &boot) + assert.Nil(t, err) + assert.Equal(t, tc.boot, boot, fmt.Sprintf("%v unexpected response, expected: %v, got: %v", tc.desc, tc.boot, boot)) + } + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + } + sdkCall.Unset() + sdkCall1.Unset() + }) + } +} + +func TestRemoveBootstrapConfigCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + bootCmd := cli.NewBootstrapCmd() + rootCmd := setFlags(bootCmd) + + cases := []struct { + desc string + args []string + sdkErr errors.SDKError + logType outputLog + errLogMessage string + }{ + { + desc: "remove bootstrap config successfully", + args: []string{ + clientID, + domainID, + validToken, + }, + logType: okLog, + }, + { + desc: "remove bootstrap config with invalid args", + args: []string{ + clientID, + domainID, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "remove bootstrap config with invalid client id", + args: []string{ + invalidID, + domainID, + validToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + logType: errLog, + }, + { + desc: "remove bootstrap config with invalid token", + args: []string{ + clientID, + domainID, + invalidToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("RemoveBootstrap", tc.args[0], tc.args[1], tc.args[2]).Return(tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{rmCmd}, tc.args...)...) + + switch tc.logType { + case okLog: + assert.True(t, strings.Contains(out, "ok"), fmt.Sprintf("%s unexpected response: expected success message, got: %v", tc.desc, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + } + sdkCall.Unset() + }) + } +} + +func TestUpdateBootstrapConfigCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + bootCmd := cli.NewBootstrapCmd() + rootCmd := setFlags(bootCmd) + + config := "config" + connection := "connection" + + newConfigJson := "{\"name\" : \"New Bootstrap\"}" + chanIDsJson := fmt.Sprintf("[\"%s\"]", channelID) + cases := []struct { + desc string + args []string + boot mgsdk.BootstrapConfig + sdkErr errors.SDKError + errLogMessage string + logType outputLog + }{ + { + desc: "update bootstrap config successfully", + args: []string{ + config, + newConfigJson, + domainID, + validToken, + }, + logType: okLog, + }, + { + desc: "update bootstrap config with invalid token", + args: []string{ + config, + newConfigJson, + domainID, + invalidToken, + }, + logType: errLog, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + }, + { + desc: "update bootstrap connections successfully", + args: []string{ + connection, + clientID, + chanIDsJson, + domainID, + validToken, + }, + logType: okLog, + }, + { + desc: "update bootstrap connections with invalid json", + args: []string{ + connection, + clientID, + fmt.Sprintf("[\"%s\"", clientID), + domainID, + validToken, + }, + sdkErr: errors.NewSDKError(errors.New("unexpected end of JSON input")), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.New("unexpected end of JSON input")), + logType: errLog, + }, + { + desc: "update bootstrap connections with invalid token", + args: []string{ + connection, + clientID, + chanIDsJson, + domainID, + invalidToken, + }, + logType: errLog, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + }, + { + desc: "update bootstrap certs successfully", + args: []string{ + "certs", + clientID, + "client cert", + "client key", + "ca", + domainID, + validToken, + }, + boot: bootConfig, + logType: entityLog, + }, + { + desc: "update bootstrap certs with invalid token", + args: []string{ + "certs", + clientID, + "client cert", + "client key", + "ca", + domainID, + invalidToken, + }, + logType: errLog, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + }, + { + desc: "update bootstrap config with invalid args", + args: []string{ + newConfigJson, + domainID, + validToken, + }, + logType: usageLog, + }, + { + desc: "update bootstrap config with invalid json", + args: []string{ + config, + "{\"name\" : \"New Bootstrap\"", + domainID, + validToken, + }, + sdkErr: errors.NewSDKError(errors.New("unexpected end of JSON input")), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.New("unexpected end of JSON input")), + logType: errLog, + }, + { + desc: "update bootstrap with invalid args", + args: []string{ + extraArg, + extraArg, + extraArg, + extraArg, + extraArg, + }, + logType: usageLog, + }, + } + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + var boot mgsdk.BootstrapConfig + sdkCall := sdkMock.On("UpdateBootstrap", mock.Anything, mock.Anything, mock.Anything).Return(tc.sdkErr) + sdkCall1 := sdkMock.On("UpdateBootstrapConnection", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.sdkErr) + sdkCall2 := sdkMock.On("UpdateBootstrapCerts", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.boot, tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{updCmd}, tc.args...)...) + + switch tc.logType { + case entityLog: + err := json.Unmarshal([]byte(out), &boot) + assert.Nil(t, err) + assert.Equal(t, tc.boot, boot, fmt.Sprintf("%s unexpected response: expected: %v, got: %v", tc.desc, tc.boot, boot)) + case okLog: + assert.True(t, strings.Contains(out, "ok"), fmt.Sprintf("%s unexpected response: expected success message, got: %v", tc.desc, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + } + sdkCall.Unset() + sdkCall1.Unset() + sdkCall2.Unset() + }) + } +} + +func TestWhitelistConfigCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + bootCmd := cli.NewBootstrapCmd() + rootCmd := setFlags(bootCmd) + + jsonConfig := fmt.Sprintf("{\"client_id\": \"%s\", \"state\":%d}", clientID, 1) + + cases := []struct { + desc string + args []string + logType outputLog + errLogMessage string + sdkErr errors.SDKError + }{ + { + desc: "whitelist config successfully", + args: []string{ + jsonConfig, + domainID, + validToken, + }, + logType: okLog, + }, + { + desc: "whitelist config with invalid args", + args: []string{ + jsonConfig, + domainID, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "whitelist config with invalid json", + args: []string{ + fmt.Sprintf("{\"client_id\": \"%s\", \"state\":%d", clientID, 1), + domainID, + validToken, + }, + sdkErr: errors.NewSDKError(errors.New("unexpected end of JSON input")), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.New("unexpected end of JSON input")), + logType: errLog, + }, + { + desc: "whitelist config with invalid token", + args: []string{ + jsonConfig, + domainID, + invalidToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("Whitelist", mock.Anything, mock.Anything, tc.args[1], tc.args[2]).Return(tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{whitelistCmd}, tc.args...)...) + switch tc.logType { + case okLog: + assert.True(t, strings.Contains(out, "ok"), fmt.Sprintf("%s unexpected response: expected success message, got: %v", tc.desc, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + } + sdkCall.Unset() + }) + } +} + +func TestBootstrapConfigCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + bootCmd := cli.NewBootstrapCmd() + rootCmd := setFlags(bootCmd) + + var boot mgsdk.BootstrapConfig + crptoKey := "v7aT0HGxJxt2gULzr3RHwf4WIf6DusPp" + invalidKey := "invalid key" + cases := []struct { + desc string + args []string + logType outputLog + errLogMessage string + sdkErr errors.SDKError + boot mgsdk.BootstrapConfig + }{ + { + desc: "bootstrap secure config successfully", + args: []string{ + "secure", + bootConfig.ExternalID, + bootConfig.ExternalKey, + crptoKey, + }, + boot: bootConfig, + logType: entityLog, + }, + { + desc: "bootstrap config successfully", + args: []string{ + bootConfig.ExternalID, + bootConfig.ExternalKey, + }, + boot: bootConfig, + logType: entityLog, + }, + { + desc: "bootstrap secure config with invalid args", + args: []string{ + crptoKey, + }, + + logType: usageLog, + }, + { + desc: "bootstrap secure config with invalid key", + args: []string{ + "secure", + bootConfig.ExternalID, + invalidKey, + crptoKey, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized)), + logType: errLog, + }, + { + desc: "bootstrap config with invalid key", + args: []string{ + bootConfig.ExternalID, + invalidKey, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusUnauthorized)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("BootstrapSecure", mock.Anything, mock.Anything, mock.Anything).Return(tc.boot, tc.sdkErr) + sdkCall1 := sdkMock.On("Bootstrap", mock.Anything, mock.Anything).Return(tc.boot, tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{bootStrapCmd}, tc.args...)...) + switch tc.logType { + case entityLog: + err := json.Unmarshal([]byte(out), &boot) + assert.Nil(t, err) + assert.Equal(t, tc.boot, boot, fmt.Sprintf("%s unexpected response: expected: %v, got: %v", tc.desc, tc.boot, boot)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + } + sdkCall.Unset() + sdkCall1.Unset() + }) + } +} diff --git a/cli/commands_test.go b/cli/commands_test.go new file mode 100644 index 000000000..7d734e439 --- /dev/null +++ b/cli/commands_test.go @@ -0,0 +1,18 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli_test + +// CRUD and common commands +const ( + createCmd = "create" + updateCmd = "update" + getCmd = "get" + enableCmd = "enable" + disableCmd = "disable" + updCmd = "update" + delCmd = "delete" + rmCmd = "remove" + whitelistCmd = "whitelist" + bootStrapCmd = "bootstrap" +) diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 000000000..e4e513d83 --- /dev/null +++ b/cli/config.go @@ -0,0 +1,319 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "io" + "net/url" + "os" + "reflect" + "strconv" + "strings" + + mgsdk "github.com/absmach/magistrala/pkg/sdk" + "github.com/absmach/supermq/pkg/errors" + "github.com/pelletier/go-toml" + "github.com/spf13/cobra" +) + +const ( + defURL string = "http://localhost" + defGroupsURL string = defURL + ":9004" + defUsersURL string = defURL + ":9002" + defChannelsURL string = defURL + ":9005" + defClientsURL string = defURL + ":9006" + defReaderURL string = defURL + ":9011" + defBootstrapURL string = defURL + ":9013" + defDomainsURL string = defURL + ":9003" + defCertsURL string = defURL + ":9019" + defInvitationsURL string = defURL + ":9020" + defHTTPURL string = defURL + ":8008" + defJournalURL string = defURL + ":9021" + defTLSVerification bool = false + defOffset string = "0" + defLimit string = "10" + defTopic string = "" + defRawOutput string = "false" +) + +type remotes struct { + ChannelsURL string `toml:"channels_url"` + ClientsURL string `toml:"clients_url"` + GroupsURL string `toml:"groups_url"` + UsersURL string `toml:"users_url"` + ReaderURL string `toml:"reader_url"` + DomainsURL string `toml:"domains_url"` + HTTPAdapterURL string `toml:"http_adapter_url"` + BootstrapURL string `toml:"bootstrap_url"` + CertsURL string `toml:"certs_url"` + InvitationsURL string `toml:"invitations_url"` + JournalURL string `toml:"journal_url"` + HostURL string `toml:"host_url"` + TLSVerification bool `toml:"tls_verification"` +} + +type filter struct { + Offset string `toml:"offset"` + Limit string `toml:"limit"` + Topic string `toml:"topic"` +} + +type config struct { + Remotes remotes `toml:"remotes"` + Filter filter `toml:"filter"` + UserToken string `toml:"user_token"` + RawOutput string `toml:"raw_output"` +} + +// Readable by all user groups but writeable by the user only. +const filePermission = 0o644 + +var ( + errReadFail = errors.New("failed to read config file") + errNoKey = errors.New("no such key") + errUnsupportedKeyValue = errors.New("unsupported data type for key") + errWritingConfig = errors.New("error in writing the updated config to file") + errInvalidURL = errors.New("invalid url") + errURLParseFail = errors.New("failed to parse url") + defaultConfigPath = "./config.toml" +) + +func read(file string) (config, error) { + c := config{} + data, err := os.Open(file) + if err != nil { + return c, errors.Wrap(errReadFail, err) + } + defer data.Close() + + buf, err := io.ReadAll(data) + if err != nil { + return c, errors.Wrap(errReadFail, err) + } + + if err := toml.Unmarshal(buf, &c); err != nil { + return config{}, err + } + + return c, nil +} + +// ParseConfig - parses the config file. +func ParseConfig(sdkConf mgsdk.Config) (mgsdk.Config, error) { + if ConfigPath == "" { + ConfigPath = defaultConfigPath + } + + _, err := os.Stat(ConfigPath) + switch { + // If the file does not exist, create it with default values. + case os.IsNotExist(err): + defaultConfig := config{ + Remotes: remotes{ + ChannelsURL: defChannelsURL, + ClientsURL: defClientsURL, + GroupsURL: defGroupsURL, + UsersURL: defUsersURL, + ReaderURL: defReaderURL, + DomainsURL: defDomainsURL, + HTTPAdapterURL: defHTTPURL, + BootstrapURL: defBootstrapURL, + CertsURL: defCertsURL, + InvitationsURL: defInvitationsURL, + JournalURL: defJournalURL, + HostURL: defURL, + TLSVerification: defTLSVerification, + }, + Filter: filter{ + Offset: defOffset, + Limit: defLimit, + Topic: defTopic, + }, + RawOutput: defRawOutput, + } + buf, err := toml.Marshal(defaultConfig) + if err != nil { + return sdkConf, err + } + if err = os.WriteFile(ConfigPath, buf, filePermission); err != nil { + return sdkConf, errors.Wrap(errWritingConfig, err) + } + case err != nil: + return sdkConf, err + } + + config, err := read(ConfigPath) + if err != nil { + return sdkConf, err + } + + if config.Filter.Offset != "" && Offset == 0 { + offset, err := strconv.ParseUint(config.Filter.Offset, 10, 64) + if err != nil { + return sdkConf, err + } + Offset = offset + } + + if config.Filter.Limit != "" && Limit == 0 { + limit, err := strconv.ParseUint(config.Filter.Limit, 10, 64) + if err != nil { + return sdkConf, err + } + Limit = limit + } + + if config.Filter.Topic != "" && Topic == "" { + Topic = config.Filter.Topic + } + + if config.RawOutput != "" { + rawOutput, err := strconv.ParseBool(config.RawOutput) + if err != nil { + return sdkConf, err + } + // check for config file value or flag input value is true + RawOutput = rawOutput || RawOutput + } + + if sdkConf.ClientsURL == "" && config.Remotes.ClientsURL != "" { + sdkConf.ClientsURL = config.Remotes.ClientsURL + } + + if sdkConf.UsersURL == "" && config.Remotes.UsersURL != "" { + sdkConf.UsersURL = config.Remotes.UsersURL + } + + if sdkConf.ReaderURL == "" && config.Remotes.ReaderURL != "" { + sdkConf.ReaderURL = config.Remotes.ReaderURL + } + + if sdkConf.DomainsURL == "" && config.Remotes.DomainsURL != "" { + sdkConf.DomainsURL = config.Remotes.DomainsURL + } + + if sdkConf.HTTPAdapterURL == "" && config.Remotes.HTTPAdapterURL != "" { + sdkConf.HTTPAdapterURL = config.Remotes.HTTPAdapterURL + } + + if sdkConf.BootstrapURL == "" && config.Remotes.BootstrapURL != "" { + sdkConf.BootstrapURL = config.Remotes.BootstrapURL + } + + if sdkConf.CertsURL == "" && config.Remotes.CertsURL != "" { + sdkConf.CertsURL = config.Remotes.CertsURL + } + + if sdkConf.InvitationsURL == "" && config.Remotes.InvitationsURL != "" { + sdkConf.InvitationsURL = config.Remotes.InvitationsURL + } + + if sdkConf.JournalURL == "" && config.Remotes.JournalURL != "" { + sdkConf.JournalURL = config.Remotes.JournalURL + } + + if sdkConf.HostURL == "" && config.Remotes.HostURL != "" { + sdkConf.HostURL = config.Remotes.HostURL + } + + sdkConf.TLSVerification = config.Remotes.TLSVerification || sdkConf.TLSVerification + + return sdkConf, nil +} + +// New config command to store params to local TOML file. +func NewConfigCmd() *cobra.Command { + return &cobra.Command{ + Use: "config ", + Short: "CLI local config", + Long: "Local param storage to prevent repetitive passing of keys", + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 2 { + logUsageCmd(*cmd, cmd.Use) + return + } + + if err := setConfigValue(args[0], args[1]); err != nil { + logErrorCmd(*cmd, err) + return + } + + logOKCmd(*cmd) + }, + } +} + +func setConfigValue(key, value string) error { + config, err := read(ConfigPath) + if err != nil { + return err + } + + if strings.Contains(key, "url") { + u, err := url.Parse(value) + if err != nil { + return errors.Wrap(errInvalidURL, err) + } + if u.Scheme == "" || u.Host == "" { + return errors.Wrap(errInvalidURL, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return errors.Wrap(errURLParseFail, err) + } + } + + configKeyToField := map[string]interface{}{ + "channels_url": &config.Remotes.ChannelsURL, + "clients_url": &config.Remotes.ClientsURL, + "groups_url": &config.Remotes.GroupsURL, + "users_url": &config.Remotes.UsersURL, + "reader_url": &config.Remotes.ReaderURL, + "http_adapter_url": &config.Remotes.HTTPAdapterURL, + "bootstrap_url": &config.Remotes.BootstrapURL, + "certs_url": &config.Remotes.CertsURL, + "tls_verification": &config.Remotes.TLSVerification, + "offset": &config.Filter.Offset, + "limit": &config.Filter.Limit, + "topic": &config.Filter.Topic, + "raw_output": &config.RawOutput, + "user_token": &config.UserToken, + } + + fieldPtr, ok := configKeyToField[key] + if !ok { + return errNoKey + } + + fieldValue := reflect.ValueOf(fieldPtr).Elem() + + switch fieldValue.Kind() { + case reflect.String: + fieldValue.SetString(value) + case reflect.Int: + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + fieldValue.SetUint(uint64(intValue)) + case reflect.Bool: + boolValue, err := strconv.ParseBool(value) + if err != nil { + return err + } + fieldValue.SetBool(boolValue) + default: + return errors.Wrap(errUnsupportedKeyValue, err) + } + + buf, err := toml.Marshal(config) + if err != nil { + return err + } + + if err = os.WriteFile(ConfigPath, buf, filePermission); err != nil { + return errors.Wrap(errWritingConfig, err) + } + + return nil +} diff --git a/cli/consumers.go b/cli/consumers.go new file mode 100644 index 000000000..64bcd6009 --- /dev/null +++ b/cli/consumers.go @@ -0,0 +1,100 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + mgsdk "github.com/absmach/magistrala/pkg/sdk" + "github.com/spf13/cobra" +) + +var cmdSubscription = []cobra.Command{ + { + Use: "create ", + Short: "Create subscription", + Long: `Create new subscription`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + id, err := sdk.CreateSubscription(args[0], args[1], args[2]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logCreatedCmd(*cmd, id) + }, + }, + { + Use: "get [all | ] ", + Short: "Get subscription", + Long: `Get subscription. + all - lists all subscriptions + - view subscription of `, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 2 { + logUsageCmd(*cmd, cmd.Use) + return + } + pageMetadata := mgsdk.PageMetadata{ + Offset: Offset, + Limit: Limit, + Topic: Topic, + Contact: Contact, + } + if args[0] == "all" { + sub, err := sdk.ListSubscriptions(pageMetadata, args[1]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + logJSONCmd(*cmd, sub) + return + } + + c, err := sdk.ViewSubscription(args[0], args[1]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, c) + }, + }, + { + Use: "remove ", + Short: "Remove subscription", + Long: `Removes removes a subscription with the provided id`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 2 { + logUsageCmd(*cmd, cmd.Use) + return + } + + if err := sdk.DeleteSubscription(args[0], args[1]); err != nil { + logErrorCmd(*cmd, err) + return + } + + logOKCmd(*cmd) + }, + }, +} + +// NewSubscriptionCmd returns subscription command. +func NewSubscriptionCmd() *cobra.Command { + cmd := cobra.Command{ + Use: "subscription [create | get | remove ]", + Short: "Subscription management", + Long: `Subscription management: create, get, or delete subscription`, + } + + for i := range cmdSubscription { + cmd.AddCommand(&cmdSubscription[i]) + } + + return &cmd +} diff --git a/cli/consumers_test.go b/cli/consumers_test.go new file mode 100644 index 000000000..0cb27696c --- /dev/null +++ b/cli/consumers_test.go @@ -0,0 +1,266 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/absmach/magistrala/cli" + "github.com/absmach/magistrala/internal/testsutil" + mgsdk "github.com/absmach/magistrala/pkg/sdk" + sdkmocks "github.com/absmach/magistrala/pkg/sdk/mocks" + "github.com/absmach/supermq/pkg/errors" + svcerr "github.com/absmach/supermq/pkg/errors/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var ( + userID = testsutil.GenerateUUID(&testing.T{}) + subscription = mgsdk.Subscription{ + ID: testsutil.GenerateUUID(&testing.T{}), + OwnerID: userID, + Topic: "topic", + Contact: "identity@example.com", + } +) + +func TestCreateSubscriptionCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + subCmd := cli.NewSubscriptionCmd() + rootCmd := setFlags(subCmd) + + cases := []struct { + desc string + args []string + logType outputLog + errLogMessage string + sdkErr errors.SDKError + response string + id string + }{ + { + desc: "create subscription successfully", + args: []string{ + subscription.Topic, + subscription.Contact, + validToken, + }, + id: userID, + response: fmt.Sprintf("\ncreated: %s\n\n", userID), + logType: createLog, + }, + { + desc: "create subscription with invalid args", + args: []string{ + subscription.Topic, + subscription.Contact, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "create subscription with invalid token", + args: []string{ + subscription.Topic, + subscription.Contact, + invalidToken, + }, + logType: errLog, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("CreateSubscription", tc.args[0], tc.args[1], tc.args[2]).Return(tc.id, tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{createCmd}, tc.args...)...) + + switch tc.logType { + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + case createLog: + assert.Equal(t, tc.response, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.response, out)) + } + sdkCall.Unset() + }) + } +} + +func TestGetSubscriptionsCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + subCmd := cli.NewSubscriptionCmd() + rootCmd := setFlags(subCmd) + + var sub mgsdk.Subscription + var page mgsdk.SubscriptionPage + + cases := []struct { + desc string + args []string + sdkErr errors.SDKError + page mgsdk.SubscriptionPage + subscription mgsdk.Subscription + logType outputLog + errLogMessage string + }{ + { + desc: "get all subscriptions successfully", + args: []string{ + all, + validToken, + }, + page: mgsdk.SubscriptionPage{ + Subscriptions: []mgsdk.Subscription{subscription}, + }, + logType: entityLog, + }, + { + desc: "get subscription with id", + args: []string{ + subscription.ID, + validToken, + }, + logType: entityLog, + subscription: subscription, + }, + { + desc: "get subscriptions with invalid args", + args: []string{ + all, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "get all subscriptions with invalid token", + args: []string{ + all, + invalidToken, + }, + logType: errLog, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + }, + { + desc: "get subscription with invalid id", + args: []string{ + invalidID, + validToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("ViewSubscription", tc.args[0], tc.args[1]).Return(tc.subscription, tc.sdkErr) + sdkCall1 := sdkMock.On("ListSubscriptions", mock.Anything, tc.args[1]).Return(tc.page, tc.sdkErr) + + out := executeCommand(t, rootCmd, append([]string{getCmd}, tc.args...)...) + + switch tc.logType { + case entityLog: + if tc.args[1] == all { + err := json.Unmarshal([]byte(out), &page) + assert.Nil(t, err) + assert.Equal(t, tc.page, page, fmt.Sprintf("%v unexpected response, expected: %v, got: %v", tc.desc, tc.page, page)) + } else { + err := json.Unmarshal([]byte(out), &sub) + assert.Nil(t, err) + assert.Equal(t, tc.subscription, sub, fmt.Sprintf("%v unexpected response, expected: %v, got: %v", tc.desc, tc.subscription, sub)) + } + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + } + sdkCall.Unset() + sdkCall1.Unset() + }) + } +} + +func TestRemoveSubscriptionCmd(t *testing.T) { + sdkMock := new(sdkmocks.SDK) + cli.SetSDK(sdkMock) + subCmd := cli.NewSubscriptionCmd() + rootCmd := setFlags(subCmd) + + cases := []struct { + desc string + args []string + sdkErr errors.SDKError + logType outputLog + errLogMessage string + }{ + { + desc: "remove subscription successfully", + args: []string{ + subscription.ID, + validToken, + }, + logType: okLog, + }, + { + desc: "remove subscription with invalid args", + args: []string{ + subscription.ID, + validToken, + extraArg, + }, + logType: usageLog, + }, + { + desc: "remove subscription with invalid subscription id", + args: []string{ + invalidID, + validToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + logType: errLog, + }, + { + desc: "remove subscription with invalid token", + args: []string{ + subscription.ID, + invalidToken, + }, + sdkErr: errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden), + errLogMessage: fmt.Sprintf("\nerror: %s\n\n", errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, http.StatusForbidden)), + logType: errLog, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + sdkCall := sdkMock.On("DeleteSubscription", tc.args[0], tc.args[1]).Return(tc.sdkErr) + out := executeCommand(t, rootCmd, append([]string{rmCmd}, tc.args...)...) + + switch tc.logType { + case okLog: + assert.True(t, strings.Contains(out, "ok"), fmt.Sprintf("%s unexpected response: expected success message, got: %v", tc.desc, out)) + case errLog: + assert.Equal(t, tc.errLogMessage, out, fmt.Sprintf("%s unexpected error response: expected %s got errLogMessage:%s", tc.desc, tc.errLogMessage, out)) + case usageLog: + assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out)) + } + sdkCall.Unset() + }) + } +} diff --git a/cli/provision.go b/cli/provision.go new file mode 100644 index 000000000..e1956753d --- /dev/null +++ b/cli/provision.go @@ -0,0 +1,410 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "time" + + "github.com/0x6flab/namegenerator" + smqsdk "github.com/absmach/supermq/pkg/sdk" + "github.com/spf13/cobra" +) + +const ( + jsonExt = ".json" + csvExt = ".csv" + PublishType = "publish" + SubscribeType = "subscribe" +) + +var ( + msgFormat = `[{"bn":"provision:", "bu":"V", "t": %d, "bver":5, "n":"voltage", "u":"V", "v":%d}]` + namesgenerator = namegenerator.NewGenerator() +) + +var cmdProvision = []cobra.Command{ + { + Use: "clients ", + Short: "Provision clients", + Long: `Bulk create clients`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + if _, err := os.Stat(args[0]); os.IsNotExist(err) { + logErrorCmd(*cmd, err) + return + } + + clients, err := clientsFromFile(args[0]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + clients, err = sdk.CreateClients(clients, args[1], args[2]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, clients) + }, + }, + { + Use: "channels ", + Short: "Provision channels", + Long: `Bulk create channels`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + channels, err := channelsFromFile(args[0]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + var chs []smqsdk.Channel + for _, c := range channels { + c, err = sdk.CreateChannel(c, args[1], args[2]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + chs = append(chs, c) + } + channels = chs + + logJSONCmd(*cmd, channels) + }, + }, + { + Use: "connect ", + Short: "Provision connections", + Long: `Bulk connect clients to channels`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 3 { + logUsageCmd(*cmd, cmd.Use) + return + } + + connIDs, err := connectionsFromFile(args[0]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + for _, conn := range connIDs { + if err := sdk.Connect(conn, args[1], args[2]); err != nil { + logErrorCmd(*cmd, err) + return + } + } + + logOKCmd(*cmd) + }, + }, + { + Use: "test", + Short: "test", + Long: `Provisions test setup: one test user, two clients and two channels. \ + Connect both clients to one of the channels, \ + and only on client to other channel.`, + Run: func(cmd *cobra.Command, args []string) { + numClients := 2 + numChan := 2 + clients := []smqsdk.Client{} + channels := []smqsdk.Channel{} + + if len(args) != 0 { + logUsageCmd(*cmd, cmd.Use) + return + } + + // Create test user + name := namesgenerator.Generate() + user := smqsdk.User{ + FirstName: name, + Email: fmt.Sprintf("%s@email.com", name), + Credentials: smqsdk.Credentials{ + Username: name, + Secret: "12345678", + }, + Status: smqsdk.EnabledStatus, + } + user, err := sdk.CreateUser(user, "") + if err != nil { + logErrorCmd(*cmd, err) + return + } + + ut, err := sdk.CreateToken(smqsdk.Login{Username: user.Credentials.Username, Password: user.Credentials.Secret}) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + // create domain + domain := smqsdk.Domain{ + Name: fmt.Sprintf("%s-domain", name), + Status: smqsdk.EnabledStatus, + } + domain, err = sdk.CreateDomain(domain, ut.AccessToken) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + ut, err = sdk.CreateToken(smqsdk.Login{Username: user.Email, Password: user.Credentials.Secret}) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + // Create clients + for i := 0; i < numClients; i++ { + t := smqsdk.Client{ + Name: fmt.Sprintf("%s-client-%d", name, i), + Status: smqsdk.EnabledStatus, + } + + clients = append(clients, t) + } + clients, err = sdk.CreateClients(clients, domain.ID, ut.AccessToken) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + // Create channels + for i := 0; i < numChan; i++ { + c := smqsdk.Channel{ + Name: fmt.Sprintf("%s-channel-%d", name, i), + Status: smqsdk.EnabledStatus, + } + c, err = sdk.CreateChannel(c, domain.ID, ut.AccessToken) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + channels = append(channels, c) + } + + // Connect clients to channels - first client to both channels, second only to first + conIDs := smqsdk.Connection{ + ChannelIDs: []string{channels[0].ID}, + ClientIDs: []string{clients[0].ID}, + Types: []string{PublishType, SubscribeType}, + } + if err := sdk.Connect(conIDs, domain.ID, ut.AccessToken); err != nil { + logErrorCmd(*cmd, err) + return + } + + conIDs = smqsdk.Connection{ + ChannelIDs: []string{channels[1].ID}, + ClientIDs: []string{clients[0].ID}, + Types: []string{PublishType, SubscribeType}, + } + if err := sdk.Connect(conIDs, domain.ID, ut.AccessToken); err != nil { + logErrorCmd(*cmd, err) + return + } + + conIDs = smqsdk.Connection{ + ChannelIDs: []string{channels[0].ID}, + ClientIDs: []string{clients[1].ID}, + Types: []string{PublishType, SubscribeType}, + } + if err := sdk.Connect(conIDs, domain.ID, ut.AccessToken); err != nil { + logErrorCmd(*cmd, err) + return + } + + // send message to test connectivity + if err := sdk.SendMessage(channels[0].ID, fmt.Sprintf(msgFormat, time.Now().Unix(), rand.Int()), clients[0].Credentials.Secret); err != nil { + logErrorCmd(*cmd, err) + return + } + if err := sdk.SendMessage(channels[0].ID, fmt.Sprintf(msgFormat, time.Now().Unix(), rand.Int()), clients[1].Credentials.Secret); err != nil { + logErrorCmd(*cmd, err) + return + } + if err := sdk.SendMessage(channels[1].ID, fmt.Sprintf(msgFormat, time.Now().Unix(), rand.Int()), clients[0].Credentials.Secret); err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, user, ut, clients, channels) + }, + }, +} + +// NewProvisionCmd returns provision command. +func NewProvisionCmd() *cobra.Command { + cmd := cobra.Command{ + Use: "provision [clients | channels | connect | test]", + Short: "Provision clients and channels from a config file", + Long: `Provision clients and channels: use json or csv file to bulk provision clients and channels`, + } + + for i := range cmdProvision { + cmd.AddCommand(&cmdProvision[i]) + } + + return &cmd +} + +func clientsFromFile(path string) ([]smqsdk.Client, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return []smqsdk.Client{}, err + } + + file, err := os.OpenFile(path, os.O_RDONLY, os.ModePerm) + if err != nil { + return []smqsdk.Client{}, err + } + defer file.Close() + + clients := []smqsdk.Client{} + switch filepath.Ext(path) { + case csvExt: + reader := csv.NewReader(file) + + for { + l, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return []smqsdk.Client{}, err + } + + if len(l) < 1 { + return []smqsdk.Client{}, errors.New("empty line found in file") + } + + client := smqsdk.Client{ + Name: l[0], + } + + clients = append(clients, client) + } + case jsonExt: + err := json.NewDecoder(file).Decode(&clients) + if err != nil { + return []smqsdk.Client{}, err + } + default: + return []smqsdk.Client{}, err + } + + return clients, nil +} + +func channelsFromFile(path string) ([]smqsdk.Channel, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return []smqsdk.Channel{}, err + } + + file, err := os.OpenFile(path, os.O_RDONLY, os.ModePerm) + if err != nil { + return []smqsdk.Channel{}, err + } + defer file.Close() + + channels := []smqsdk.Channel{} + switch filepath.Ext(path) { + case csvExt: + reader := csv.NewReader(file) + + for { + l, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return []smqsdk.Channel{}, err + } + + if len(l) < 1 { + return []smqsdk.Channel{}, errors.New("empty line found in file") + } + + channel := smqsdk.Channel{ + Name: l[0], + } + + channels = append(channels, channel) + } + case jsonExt: + err := json.NewDecoder(file).Decode(&channels) + if err != nil { + return []smqsdk.Channel{}, err + } + default: + return []smqsdk.Channel{}, err + } + + return channels, nil +} + +func connectionsFromFile(path string) ([]smqsdk.Connection, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return []smqsdk.Connection{}, err + } + + file, err := os.OpenFile(path, os.O_RDONLY, os.ModePerm) + if err != nil { + return []smqsdk.Connection{}, err + } + defer file.Close() + + connections := []smqsdk.Connection{} + switch filepath.Ext(path) { + case csvExt: + reader := csv.NewReader(file) + + for { + l, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return []smqsdk.Connection{}, err + } + + if len(l) < 1 { + return []smqsdk.Connection{}, errors.New("empty line found in file") + } + connections = append(connections, smqsdk.Connection{ + ClientIDs: []string{l[0]}, + ChannelIDs: []string{l[1]}, + Types: []string{PublishType, SubscribeType}, + }) + } + case jsonExt: + err := json.NewDecoder(file).Decode(&connections) + if err != nil { + return []smqsdk.Connection{}, err + } + default: + return []smqsdk.Connection{}, err + } + + return connections, nil +} diff --git a/cli/sdk.go b/cli/sdk.go new file mode 100644 index 000000000..2e7a9cad9 --- /dev/null +++ b/cli/sdk.go @@ -0,0 +1,14 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import mgsdk "github.com/absmach/magistrala/pkg/sdk" + +// Keep SDK handle in global var. +var sdk mgsdk.SDK + +// SetSDK sets supermq SDK instance. +func SetSDK(s mgsdk.SDK) { + sdk = s +} diff --git a/cli/setup_test.go b/cli/setup_test.go new file mode 100644 index 000000000..a34218f80 --- /dev/null +++ b/cli/setup_test.go @@ -0,0 +1,120 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli_test + +import ( + "bytes" + "testing" + + "github.com/absmach/supermq/cli" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +type outputLog uint8 + +const ( + usageLog outputLog = iota + errLog + entityLog + okLog + createLog + revokeLog +) + +func executeCommand(t *testing.T, root *cobra.Command, args ...string) string { + buffer := new(bytes.Buffer) + root.SetOut(buffer) + root.SetErr(buffer) + root.SetArgs(args) + err := root.Execute() + assert.NoError(t, err, "Error executing command") + return buffer.String() +} + +func setFlags(rootCmd *cobra.Command) *cobra.Command { + // Root Flags + rootCmd.PersistentFlags().BoolVarP( + &cli.RawOutput, + "raw", + "r", + cli.RawOutput, + "Enables raw output mode for easier parsing of output", + ) + + // Client and Channels Flags + rootCmd.PersistentFlags().Uint64VarP( + &cli.Limit, + "limit", + "l", + 10, + "Limit query parameter", + ) + + rootCmd.PersistentFlags().Uint64VarP( + &cli.Offset, + "offset", + "o", + 0, + "Offset query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Name, + "name", + "n", + "", + "Name query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Identity, + "identity", + "I", + "", + "User identity query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Metadata, + "metadata", + "m", + "", + "Metadata query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Status, + "status", + "S", + "", + "User status query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.State, + "state", + "z", + "", + "Bootstrap state query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Topic, + "topic", + "T", + "", + "Subscription topic query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Contact, + "contact", + "C", + "", + "Subscription contact query parameter", + ) + + return rootCmd +} diff --git a/cli/utils.go b/cli/utils.go new file mode 100644 index 000000000..dcbc4dd2f --- /dev/null +++ b/cli/utils.go @@ -0,0 +1,85 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/fatih/color" + "github.com/hokaccha/go-prettyjson" + "github.com/spf13/cobra" +) + +var ( + // Limit query parameter. + Limit uint64 = 10 + // Offset query parameter. + Offset uint64 = 0 + // Name query parameter. + Name string = "" + // Identity query parameter. + Identity string = "" + // Metadata query parameter. + Metadata string = "" + // Status query parameter. + Status string = "" + // ConfigPath config path parameter. + ConfigPath string = "" + // State query parameter. + State string = "" + // Topic query parameter. + Topic string = "" + // Contact query parameter. + Contact string = "" + // RawOutput raw output mode. + RawOutput bool = false + // Username query parameter. + Username string = "" + // FirstName query parameter. + FirstName string = "" + // LastName query parameter. + LastName string = "" +) + +func logJSONCmd(cmd cobra.Command, iList ...interface{}) { + for _, i := range iList { + m, err := json.Marshal(i) + if err != nil { + logErrorCmd(cmd, err) + return + } + + pj, err := prettyjson.Format(m) + if err != nil { + logErrorCmd(cmd, err) + return + } + + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n\n", string(pj)) + } +} + +func logUsageCmd(cmd cobra.Command, u string) { + fmt.Fprintf(cmd.OutOrStdout(), color.YellowString("\nusage: %s\n\n"), u) +} + +func logErrorCmd(cmd cobra.Command, err error) { + boldRed := color.New(color.FgRed, color.Bold) + boldRed.Fprintf(cmd.ErrOrStderr(), "\nerror: ") + + fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\n", color.RedString(err.Error())) +} + +func logOKCmd(cmd cobra.Command) { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n\n", color.BlueString("ok")) +} + +func logCreatedCmd(cmd cobra.Command, e string) { + if RawOutput { + fmt.Fprintln(cmd.OutOrStdout(), e) + } else { + fmt.Fprintf(cmd.OutOrStdout(), color.BlueString("\ncreated: %s\n\n"), e) + } +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 000000000..e8f1c674f --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,281 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +// Package main contains cli main function to run the cli. +package main + +import ( + "log" + + "github.com/absmach/magistrala/cli" + mgcli "github.com/absmach/magistrala/cli" + mgsdk "github.com/absmach/magistrala/pkg/sdk" + smqcli "github.com/absmach/supermq/cli" + smqsdk "github.com/absmach/supermq/pkg/sdk" + "github.com/spf13/cobra" +) + +func main() { + msgContentType := string(smqsdk.CTJSONSenML) + smqsdkConf := smqsdk.Config{ + MsgContentType: smqsdk.ContentType(msgContentType), + } + mgsdkConf := mgsdk.Config{ + MsgContentType: smqsdk.ContentType(msgContentType), + } + + // Root + rootCmd := &cobra.Command{ + Use: "magistrala-cli", + PersistentPreRun: func(_ *cobra.Command, _ []string) { + smqcliConf, err := smqcli.ParseConfig(smqsdkConf) + if err != nil { + log.Fatalf("Failed to parse config: %s", err) + } + if smqcliConf.MsgContentType == "" { + smqcliConf.MsgContentType = smqsdk.ContentType(msgContentType) + } + ss := smqsdk.NewSDK(smqcliConf) + smqcli.SetSDK(ss) + + mgcliConf, err := mgcli.ParseConfig(mgsdkConf) + if err != nil { + log.Fatalf("Failed to parse config: %s", err) + } + if mgcliConf.MsgContentType == "" { + mgcliConf.MsgContentType = smqsdk.ContentType(msgContentType) + } + ms := mgsdk.NewSDK(mgcliConf) + mgcli.SetSDK(ms) + }, + } + // SuperMQ API commands + healthCmd := smqcli.NewHealthCmd() + usersCmd := smqcli.NewUsersCmd() + domainsCmd := smqcli.NewDomainsCmd() + clientsCmd := smqcli.NewClientsCmd() + groupsCmd := smqcli.NewGroupsCmd() + channelsCmd := smqcli.NewChannelsCmd() + messagesCmd := smqcli.NewMessagesCmd() + certsCmd := smqcli.NewCertsCmd() + configCmd := smqcli.NewConfigCmd() + invitationsCmd := smqcli.NewInvitationsCmd() + journalCmd := smqcli.NewJournalCmd() + + // Magistrala API commands + provisionCmd := mgcli.NewProvisionCmd() + bootstrapCmd := mgcli.NewBootstrapCmd() + subscriptionsCmd := mgcli.NewSubscriptionCmd() + + // Root Commands + rootCmd.AddCommand(healthCmd) + rootCmd.AddCommand(usersCmd) + rootCmd.AddCommand(domainsCmd) + rootCmd.AddCommand(groupsCmd) + rootCmd.AddCommand(clientsCmd) + rootCmd.AddCommand(channelsCmd) + rootCmd.AddCommand(messagesCmd) + rootCmd.AddCommand(provisionCmd) + rootCmd.AddCommand(bootstrapCmd) + rootCmd.AddCommand(certsCmd) + rootCmd.AddCommand(subscriptionsCmd) + rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(invitationsCmd) + rootCmd.AddCommand(journalCmd) + + // Root Flags + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.BootstrapURL, + "bootstrap-url", + "b", + mgsdkConf.BootstrapURL, + "Bootstrap service URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.CertsURL, + "certs-url", + "s", + mgsdkConf.CertsURL, + "Certs service URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.ClientsURL, + "clients-url", + "t", + mgsdkConf.ClientsURL, + "Clients service URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.UsersURL, + "users-url", + "u", + mgsdkConf.UsersURL, + "Users service URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.DomainsURL, + "domains-url", + "d", + mgsdkConf.DomainsURL, + "Domains service URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.HTTPAdapterURL, + "http-url", + "p", + mgsdkConf.HTTPAdapterURL, + "HTTP adapter URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.ReaderURL, + "reader-url", + "R", + mgsdkConf.ReaderURL, + "Reader URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.InvitationsURL, + "invitations-url", + "v", + mgsdkConf.InvitationsURL, + "Inivitations URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.JournalURL, + "journal-url", + "a", + mgsdkConf.JournalURL, + "Journal Log URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &mgsdkConf.HostURL, + "host-url", + "H", + mgsdkConf.HostURL, + "Host URL", + ) + + rootCmd.PersistentFlags().StringVarP( + &msgContentType, + "content-type", + "y", + msgContentType, + "Message content type", + ) + + rootCmd.PersistentFlags().BoolVarP( + &mgsdkConf.TLSVerification, + "insecure", + "i", + mgsdkConf.TLSVerification, + "Do not check for TLS cert", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.ConfigPath, + "config", + "c", + cli.ConfigPath, + "Config path", + ) + + rootCmd.PersistentFlags().BoolVarP( + &cli.RawOutput, + "raw", + "r", + cli.RawOutput, + "Enables raw output mode for easier parsing of output", + ) + rootCmd.PersistentFlags().BoolVarP( + &mgsdkConf.CurlFlag, + "curl", + "x", + false, + "Convert HTTP request to cURL command", + ) + + // Client and Channels Flags + rootCmd.PersistentFlags().Uint64VarP( + &cli.Limit, + "limit", + "l", + 10, + "Limit query parameter", + ) + + rootCmd.PersistentFlags().Uint64VarP( + &cli.Offset, + "offset", + "o", + 0, + "Offset query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Name, + "name", + "n", + "", + "Name query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Identity, + "identity", + "I", + "", + "User identity query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Metadata, + "metadata", + "m", + "", + "Metadata query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Status, + "status", + "S", + "", + "User status query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.State, + "state", + "z", + "", + "Bootstrap state query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Topic, + "topic", + "T", + "", + "Subscription topic query parameter", + ) + + rootCmd.PersistentFlags().StringVarP( + &cli.Contact, + "contact", + "C", + "", + "Subscription contact query parameter", + ) + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/config.toml b/config.toml index 074584731..22871b96b 100644 --- a/config.toml +++ b/config.toml @@ -13,11 +13,13 @@ user_token = "" journal_url = "http://localhost:9021" bootstrap_url = "http://localhost:9013" certs_url = "http://localhost:9019" - domains_url = "http://localhost:8189" + domains_url = "http://localhost:9003" host_url = "http://localhost" http_adapter_url = "http://localhost:8008" invitations_url = "http://localhost:9020" reader_url = "http://localhost:9011" - things_url = "http://localhost:9000" - tls_verification = false + clients_url = "http://localhost:9006" + channels_url = "http://localhost:9005" + groups_url = "http://localhost:9004" users_url = "http://localhost:9002" + tls_verification = false diff --git a/go.mod b/go.mod index 23916fb9f..7f396017f 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.18.0 github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -74,6 +74,7 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index b57418ff1..1586f4c38 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3Ar github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= +github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= diff --git a/pkg/sdk/sdk.go b/pkg/sdk/sdk.go index e17762521..d182f1675 100644 --- a/pkg/sdk/sdk.go +++ b/pkg/sdk/sdk.go @@ -33,6 +33,8 @@ type PageMetadata struct { Contact string `json:"contact,omitempty"` DomainID string `json:"domain_id,omitempty"` Level uint64 `json:"level,omitempty"` + State string `json:"state,omitempty"` + Name string `json:"name,omitempty"` } type MessagePageMetadata struct {