diff --git a/cmd/client/api/api.go b/cmd/client/api/api.go index 7ebcc6b..1cf3359 100644 --- a/cmd/client/api/api.go +++ b/cmd/client/api/api.go @@ -62,4 +62,7 @@ const ( API_BACKUP_LIST = "/api/backup" API_BACKUP_DELETE = "/api/backup" + + API_SETTINGS = "/api/settings" + API_SETTINGS_SYSTEM = "/api/settings/system" ) diff --git a/cmd/client/api/settings.go b/cmd/client/api/settings.go new file mode 100644 index 0000000..4f53e3d --- /dev/null +++ b/cmd/client/api/settings.go @@ -0,0 +1,84 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/mycontroller-org/server/v2/pkg/json" + settingsTY "github.com/mycontroller-org/server/v2/pkg/types/settings" +) + +func (c *Client) GetSystemSettings() (*settingsTY.Settings, error) { + res, err := c.executeJson(API_SETTINGS_SYSTEM, http.MethodGet, nil, nil, nil, http.StatusOK) + if err != nil { + return nil, err + } + item := &settingsTY.Settings{} + if err := json.Unmarshal(res.Body, item); err != nil { + return nil, err + } + return item, nil +} + +func (c *Client) UpdateSettings(settings *settingsTY.Settings) error { + _, err := c.executeJson(API_SETTINGS, http.MethodPost, nil, nil, settings, http.StatusOK) + return err +} + +func (c *Client) SetSystemSettingsPath(keyPath, value string, rawText bool) error { + settings, err := c.GetSystemSettings() + if err != nil { + return err + } + if settings.Spec == nil { + settings.Spec = map[string]interface{}{} + } + updated := map[string]interface{}{} + if err := applyJSONPath(settings.Spec, keyPath, value, rawText, &updated); err != nil { + return err + } + settings.ID = settingsTY.KeySystemSettings + settings.Spec = updated + return c.UpdateSettings(settings) +} + +func (c *Client) MergeSystemSettings(overlay map[string]interface{}) error { + if len(overlay) == 0 { + return fmt.Errorf("settings overlay is empty") + } + settings, err := c.GetSystemSettings() + if err != nil { + return err + } + if spec, ok := overlay["spec"].(map[string]interface{}); ok && overlay["id"] != nil { + overlay = spec + } + settings.ID = settingsTY.KeySystemSettings + settings.Spec = mergeSettingMaps(settings.Spec, overlay) + return c.UpdateSettings(settings) +} + +func mergeSettingMaps(base, overlay map[string]interface{}) map[string]interface{} { + if base == nil { + base = map[string]interface{}{} + } + out := make(map[string]interface{}, len(base)+len(overlay)) + for key, value := range base { + out[key] = value + } + for key, value := range overlay { + existing, ok := out[key] + if !ok { + out[key] = value + continue + } + existingMap, existingIsMap := existing.(map[string]interface{}) + valueMap, valueIsMap := value.(map[string]interface{}) + if existingIsMap && valueIsMap { + out[key] = mergeSettingMaps(existingMap, valueMap) + continue + } + out[key] = value + } + return out +} diff --git a/cmd/client/api/settings_test.go b/cmd/client/api/settings_test.go new file mode 100644 index 0000000..a6bb113 --- /dev/null +++ b/cmd/client/api/settings_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeSettingMaps(t *testing.T) { + base := map[string]interface{}{ + "language": "en", + "geoLocation": map[string]interface{}{ + "autoUpdate": true, + "locationName": "here", + }, + } + overlay := map[string]interface{}{ + "language": "de", + "geoLocation": map[string]interface{}{ + "latitude": 1.5, + }, + } + merged := mergeSettingMaps(base, overlay) + assert.Equal(t, "de", merged["language"]) + geo := merged["geoLocation"].(map[string]interface{}) + assert.Equal(t, true, geo["autoUpdate"]) + assert.Equal(t, "here", geo["locationName"]) + assert.Equal(t, 1.5, geo["latitude"]) +} + +func TestApplyJSONPathOnSettingsSpec(t *testing.T) { + spec := map[string]interface{}{ + "language": "en", + "geoLocation": map[string]interface{}{ + "autoUpdate": false, + }, + } + out := map[string]interface{}{} + require.NoError(t, applyJSONPath(spec, "geoLocation.autoUpdate", "true", false, &out)) + geo := out["geoLocation"].(map[string]interface{}) + assert.Equal(t, true, geo["autoUpdate"]) + assert.Equal(t, "en", out["language"]) +} diff --git a/cmd/client/command/action/cmd.go b/cmd/client/command/action/cmd.go index d4d1f50..108c224 100644 --- a/cmd/client/command/action/cmd.go +++ b/cmd/client/command/action/cmd.go @@ -24,9 +24,8 @@ var actionCmd = &cobra.Command{ Node actions: reboot, reset, firmware-update, heartbeat, refresh-node-info Gateway actions: discover-nodes -Node ids are quick ids: gatewayId.nodeId (for example mysensor.1). -Gateway ids are the gateway id (for example mysensor). -Reload a gateway with myc reload gateway; there is no gateway restart action. + myc action node reboot mysensor.1 + myc action gateway discover-nodes mysensor `, SilenceUsage: true, SilenceErrors: true, @@ -36,27 +35,27 @@ Reload a gateway with myc reload gateway; there is no gateway restart action. } var nodeActionCmd = &cobra.Command{ - Use: "node [...]", + Use: "node [...]", Aliases: []string{"nodes"}, Short: "Send an action to one or more nodes", - Example: ` myc action node reboot mysensor.1 mysensor.2 - myc action node reset mysensor.1 - myc action node firmware-update mysensor.1 - myc action node heartbeat mysensor.1 - myc action node refresh-node-info mysensor.1`, - Args: cobra.MinimumNArgs(2), + Example: ` myc action node reboot mysensor.1 mysensor.2 + myc action node reset mysensor.1 + myc action node firmware-update mysensor.1 + myc action node heartbeat mysensor.1 + myc action node refresh-node-info mysensor.1`, + Args: cobra.MinimumNArgs(3), SilenceUsage: true, SilenceErrors: true, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { - action, err := normalizeNodeAction(args[0]) + client, rest := rootCmd.TakeAlias(args) + action, err := normalizeNodeAction(rest[0]) if err != nil { return err } - client := rootCmd.GetClient() - ids, resolveErr := client.ResolveNodeIDs(args[1:]) + ids, resolveErr := client.ResolveNodeIDs(rest[1:]) if len(ids) > 0 { if err := client.ExecuteNodeAction(action, ids); err != nil { return err @@ -68,23 +67,23 @@ var nodeActionCmd = &cobra.Command{ } var gatewayActionCmd = &cobra.Command{ - Use: "gateway [...]", + Use: "gateway [...]", Aliases: []string{"gw", "gateways"}, Short: "Send an action to one or more gateways", - Example: ` myc action gateway discover-nodes mysensor gw2`, - Args: cobra.MinimumNArgs(2), + Example: ` myc action gateway discover-nodes mysensor gw2`, + Args: cobra.MinimumNArgs(3), SilenceUsage: true, SilenceErrors: true, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { - action, err := normalizeGatewayAction(args[0]) + client, rest := rootCmd.TakeAlias(args) + action, err := normalizeGatewayAction(rest[0]) if err != nil { return err } - client := rootCmd.GetClient() - ids, resolveErr := client.ResolveGatewayIDs(args[1:], true) + ids, resolveErr := client.ResolveGatewayIDs(rest[1:], true) if len(ids) > 0 { if err := client.ExecuteGatewayAction(action, ids); err != nil { return err diff --git a/cmd/client/command/alias/cmd.go b/cmd/client/command/alias/cmd.go new file mode 100644 index 0000000..b48e969 --- /dev/null +++ b/cmd/client/command/alias/cmd.go @@ -0,0 +1,228 @@ +package alias + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + "time" + + "github.com/mycontroller-org/server/v2/cmd/client/api" + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + clientTY "github.com/mycontroller-org/server/v2/pkg/types/client" + "github.com/mycontroller-org/server/v2/pkg/utils/printer" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var aliasNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + +var reservedAliasNames = map[string]struct{}{ + "alias": {}, "apply": {}, "action": {}, "completion": {}, "delete": {}, + "disable": {}, "enable": {}, "get": {}, "help": {}, "reboot": {}, + "reload": {}, "server": {}, "set": {}, "upload": {}, "myc": {}, +} + +var ( + aliasUsername string + aliasPassword string + aliasToken string + aliasExpiresIn string + aliasInsecure bool +) + +func init() { + rootCmd.Cmd.AddCommand(aliasCmd) + aliasCmd.AddCommand(aliasSetCmd) + aliasCmd.AddCommand(aliasListCmd) + aliasCmd.AddCommand(aliasRemoveCmd) + + aliasSetCmd.Flags().StringVarP(&aliasUsername, "username", "u", "", "username to login") + aliasSetCmd.Flags().StringVarP(&aliasPassword, "password", "p", "", "password to login") + aliasSetCmd.Flags().StringVarP(&aliasToken, "token", "t", "", "service token to login") + aliasSetCmd.Flags().StringVar(&aliasExpiresIn, "expires-in", "720h", "session expires in") + aliasSetCmd.Flags().BoolVar(&aliasInsecure, "insecure", false, "skip TLS certificate verification") +} + +var aliasCmd = &cobra.Command{ + Use: "alias", + Short: "Manage named server connections", + Long: `Aliases store a server URL and a logged-in user session. +Every server command takes the alias as its first argument. + + myc alias set http://localhost:8080 + myc alias set https://mc.example.com -u admin --insecure + myc alias list + myc get node + myc apply -f resources.yaml +`, + SilenceUsage: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, +} + +var aliasSetCmd = &cobra.Command{ + Use: "set ", + Short: "Add or update an alias and log in", + Example: ` myc alias set http://localhost:8080 + myc alias set http://localhost:8080 -u admin + myc alias set https://mc.example.com -u admin -p secret --insecure + myc alias set http://localhost:8080 --token `, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + name, url := args[0], strings.TrimRight(args[1], "/") + if err := validateAliasName(name); err != nil { + return err + } + if url == "" { + return fmt.Errorf("url is required") + } + + username := aliasUsername + password := aliasPassword + if aliasToken == "" { + if username == "" { + var err error + username, err = promptUsername() + if err != nil { + return err + } + } + if password == "" { + var err error + password, err = promptPassword() + if err != nil { + return err + } + } + } + + client := api.NewClient(url, "", aliasInsecure) + res, err := client.Login(username, password, aliasToken, aliasExpiresIn) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + rootCmd.CONFIG.EnsureAliases() + rootCmd.CONFIG.Aliases[name] = clientTY.Alias{ + URL: url, + Insecure: aliasInsecure, + Username: username, + Password: res.Token, + LoginTime: time.Now().Format(time.RFC3339), + ExpiresIn: aliasExpiresIn, + } + rootCmd.WriteConfigFile() + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "alias %q is ready\n", name) + return nil + }, +} + +var aliasListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List configured aliases", + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + rootCmd.CONFIG.EnsureAliases() + if len(rootCmd.CONFIG.Aliases) == 0 { + _, _ = fmt.Fprintln(rootCmd.IOStreams.Out, "no aliases. add one with: myc alias set ") + return nil + } + names := make([]string, 0, len(rootCmd.CONFIG.Aliases)) + for name := range rootCmd.CONFIG.Aliases { + names = append(names, name) + } + sort.Strings(names) + + headers := []printer.Header{ + {Title: "name", ValuePath: "Name"}, + {Title: "url", ValuePath: "URL"}, + {Title: "user", ValuePath: "User"}, + {Title: "insecure", ValuePath: "Insecure"}, + } + rows := make([]interface{}, 0, len(names)) + for _, name := range names { + a := rootCmd.CONFIG.Aliases[name] + rows = append(rows, aliasRow{ + Name: name, + URL: a.URL, + User: a.Username, + Insecure: a.Insecure, + }) + } + printer.Print(rootCmd.IOStreams.Out, headers, rows, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) + return nil + }, +} + +var aliasRemoveCmd = &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove an alias", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + rootCmd.CONFIG.EnsureAliases() + if _, ok := rootCmd.CONFIG.Aliases[name]; !ok { + return fmt.Errorf("alias %q is not configured", name) + } + delete(rootCmd.CONFIG.Aliases, name) + rootCmd.WriteConfigFile() + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "removed alias %q\n", name) + return nil + }, +} + +type aliasRow struct { + Name string + URL string + User string + Insecure bool +} + +func validateAliasName(name string) error { + if !aliasNamePattern.MatchString(name) { + return fmt.Errorf("invalid alias name %q (use letters, numbers, - or _ and start with a letter)", name) + } + if _, reserved := reservedAliasNames[strings.ToLower(name)]; reserved { + return fmt.Errorf("alias name %q is reserved", name) + } + return nil +} + +func promptUsername() (string, error) { + var username string + _, err := fmt.Fprint(rootCmd.IOStreams.Out, "Username: ") + if err != nil { + return "", err + } + _, err = fmt.Fscanln(rootCmd.IOStreams.In, &username) + return username, err +} + +func promptPassword() (string, error) { + _, _ = fmt.Fprint(rootCmd.IOStreams.Out, "Password: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + _, _ = fmt.Fprintln(rootCmd.IOStreams.Out) + if err != nil { + return "", err + } + return string(pw), nil +} diff --git a/cmd/client/command/alias/cmd_test.go b/cmd/client/command/alias/cmd_test.go new file mode 100644 index 0000000..eb8b084 --- /dev/null +++ b/cmd/client/command/alias/cmd_test.go @@ -0,0 +1,20 @@ +package alias + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateAliasName(t *testing.T) { + require.NoError(t, validateAliasName("home")) + require.NoError(t, validateAliasName("prod-1")) + require.NoError(t, validateAliasName("Lab_A")) + require.Error(t, validateAliasName("")) + require.Error(t, validateAliasName("1prod")) + require.Error(t, validateAliasName("has space")) + require.Error(t, validateAliasName("get")) + require.Error(t, validateAliasName("GET")) + assert.Contains(t, validateAliasName("apply").Error(), "reserved") +} diff --git a/cmd/client/command/apply/cmd.go b/cmd/client/command/apply/cmd.go index f345552..060a708 100644 --- a/cmd/client/command/apply/cmd.go +++ b/cmd/client/command/apply/cmd.go @@ -25,7 +25,7 @@ func init() { } var applyCmd = &cobra.Command{ - Use: "apply", + Use: "apply ", Short: "Add, merge, or delete resources from a YAML or JSON file", SilenceUsage: true, SilenceErrors: true, @@ -86,15 +86,17 @@ YAML example: If an item includes fieldId, it is applied as a field even when kind is source. A JSON array of the same objects is also supported. `, - Example: ` myc apply -f resources.yaml - myc apply -f resources.yaml --dry-run - myc apply -f nodes.yaml -f sources.yaml --replace - myc apply -f - --dry-run < resources.json`, + Example: ` myc apply -f resources.yaml + myc apply -f resources.yaml --dry-run + myc apply -f nodes.yaml -f sources.yaml --replace + myc apply -f - --dry-run < resources.json`, + Args: cobra.ExactArgs(1), PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { - err := runApply(newAPIResourceClient(rootCmd.GetClient()), filenameSlice, replace, dryRun, rootCmd.IOStreams.In, rootCmd.IOStreams.Out, rootCmd.IOStreams.ErrOut) + client := rootCmd.MustClient(args[0]) + err := runApply(newAPIResourceClient(client), filenameSlice, replace, dryRun, rootCmd.IOStreams.In, rootCmd.IOStreams.Out, rootCmd.IOStreams.ErrOut) if errors.Is(err, ErrApplyFailed) { os.Exit(1) } diff --git a/cmd/client/command/delete/delete_cmd.go b/cmd/client/command/delete/delete_cmd.go index 5e30ee6..7a5dffe 100644 --- a/cmd/client/command/delete/delete_cmd.go +++ b/cmd/client/command/delete/delete_cmd.go @@ -22,196 +22,196 @@ func init() { } var gwDeleteCmd = &cobra.Command{ - Use: "gateway", + Use: "gateway [...]", Aliases: []string{"gw", "gateways"}, Short: "Deletes the given gateways", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteGateway(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteGateway(ids...) printStatus(err) }, } var nodeDeleteCmd = &cobra.Command{ - Use: "node", + Use: "node [...]", Aliases: []string{"nodes"}, Short: "Deletes the given nodes", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteNode(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteNode(ids...) printStatus(err) }, } var sourceDeleteCmd = &cobra.Command{ - Use: "source", + Use: "source [...]", Aliases: []string{"sources"}, Short: "Deletes the given sources", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteSource(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteSource(ids...) printStatus(err) }, } var fieldDeleteCmd = &cobra.Command{ - Use: "field", + Use: "field [...]", Aliases: []string{"fields"}, Short: "Deletes the given fields", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteField(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteField(ids...) printStatus(err) }, } var firmwareDeleteCmd = &cobra.Command{ - Use: "firmware", + Use: "firmware [...]", Aliases: []string{"firmwares", "fw"}, Short: "Deletes the given firmwares", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteFirmware(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteFirmware(ids...) printStatus(err) }, } var dataRepositoryDeleteCmd = &cobra.Command{ - Use: "data-repository", + Use: "data-repository [...]", Aliases: []string{"data-repositories", "data-repo"}, Short: "Deletes the given data repositories", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteDataRepository(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteDataRepository(ids...) printStatus(err) }, } var virtualDeviceDeleteCmd = &cobra.Command{ - Use: "virtual-device", + Use: "virtual-device [...]", Aliases: []string{"virtual-devices", "vd"}, Short: "Deletes the given virtual devices", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteVirtualDevice(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteVirtualDevice(ids...) printStatus(err) }, } var virtualAssistantDeleteCmd = &cobra.Command{ - Use: "virtual-assistant", + Use: "virtual-assistant [...]", Aliases: []string{"virtual-assistants", "va"}, Short: "Deletes the given virtual assistants", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteVirtualAssistant(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteVirtualAssistant(ids...) printStatus(err) }, } var taskDeleteCmd = &cobra.Command{ - Use: "task", + Use: "task [...]", Aliases: []string{"tasks"}, Short: "Deletes the given tasks", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteTask(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteTask(ids...) printStatus(err) }, } var scheduleDeleteCmd = &cobra.Command{ - Use: "schedule", + Use: "schedule [...]", Aliases: []string{"schedules"}, Short: "Deletes the given schedules", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteSchedule(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteSchedule(ids...) printStatus(err) }, } var handlerDeleteCmd = &cobra.Command{ - Use: "handler", + Use: "handler [...]", Aliases: []string{"handlers"}, Short: "Deletes the given handlers", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteHandler(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteHandler(ids...) printStatus(err) }, } var forwardPayloadDeleteCmd = &cobra.Command{ - Use: "forward-payload", + Use: "forward-payload [...]", Aliases: []string{"forward-payloads"}, Short: "Deletes the given forward payloads", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteForwardPayload(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteForwardPayload(ids...) printStatus(err) }, } var backupDeleteCmd = &cobra.Command{ - Use: "backup", + Use: "backup [...]", Aliases: []string{"backups"}, Short: "Deletes the given backups", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DeleteBackup(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DeleteBackup(ids...) printStatus(err) }, } diff --git a/cmd/client/command/disable/disable_cmd.go b/cmd/client/command/disable/disable_cmd.go index f3fb158..32bbf60 100644 --- a/cmd/client/command/disable/disable_cmd.go +++ b/cmd/client/command/disable/disable_cmd.go @@ -15,91 +15,91 @@ func init() { } var gatewayDisableCmd = &cobra.Command{ - Use: "gateway", + Use: "gateway [...]", Aliases: []string{"gw", "gateways"}, Short: "Disables the given gateways", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DisableGateway(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DisableGateway(ids...) printStatus(err) }, } var virtualDeviceDisableCmd = &cobra.Command{ - Use: "virtual-device", + Use: "virtual-device [...]", Aliases: []string{"virtual-devices", "vd"}, Short: "Disables the given virtual devices", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DisableVirtualDevice(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DisableVirtualDevice(ids...) printStatus(err) }, } var virtualAssistantDisableCmd = &cobra.Command{ - Use: "virtual-assistant", + Use: "virtual-assistant [...]", Aliases: []string{"virtual-assistants", "va"}, Short: "Disables the given virtual assistants", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DisableVirtualAssistant(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DisableVirtualAssistant(ids...) printStatus(err) }, } var taskDisableCmd = &cobra.Command{ - Use: "task", + Use: "task [...]", Aliases: []string{"tasks"}, Short: "Disables the given tasks", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DisableTask(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DisableTask(ids...) printStatus(err) }, } var scheduleDisableCmd = &cobra.Command{ - Use: "schedule", + Use: "schedule [...]", Aliases: []string{"schedules"}, Short: "Disables the given schedules", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DisableSchedule(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DisableSchedule(ids...) printStatus(err) }, } var handlerDisableCmd = &cobra.Command{ - Use: "handler", + Use: "handler [...]", Aliases: []string{"handlers"}, Short: "Disables the given handlers", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.DisableHandler(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.DisableHandler(ids...) printStatus(err) }, } diff --git a/cmd/client/command/enable/enable_cmd.go b/cmd/client/command/enable/enable_cmd.go index f74451f..5d3baee 100644 --- a/cmd/client/command/enable/enable_cmd.go +++ b/cmd/client/command/enable/enable_cmd.go @@ -15,91 +15,91 @@ func init() { } var gatewayEnableCmd = &cobra.Command{ - Use: "gateway", + Use: "gateway [...]", Aliases: []string{"gw", "gateways"}, Short: "Enables the given gateways", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.EnableGateway(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.EnableGateway(ids...) printStatus(err) }, } var virtualDeviceEnableCmd = &cobra.Command{ - Use: "virtual-device", + Use: "virtual-device [...]", Aliases: []string{"virtual-devices", "vd"}, Short: "Enables the given virtual devices", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.EnableVirtualDevice(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.EnableVirtualDevice(ids...) printStatus(err) }, } var virtualAssistantEnableCmd = &cobra.Command{ - Use: "virtual-assistant", + Use: "virtual-assistant [...]", Aliases: []string{"virtual-assistants", "va"}, Short: "Enables the given virtual assistants", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.EnableVirtualAssistant(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.EnableVirtualAssistant(ids...) printStatus(err) }, } var taskEnableCmd = &cobra.Command{ - Use: "task", + Use: "task [...]", Aliases: []string{"tasks"}, Short: "Enables the given tasks", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.EnableTask(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.EnableTask(ids...) printStatus(err) }, } var scheduleEnableCmd = &cobra.Command{ - Use: "schedule", + Use: "schedule [...]", Aliases: []string{"schedules"}, Short: "Enables the given schedules", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.EnableSchedule(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.EnableSchedule(ids...) printStatus(err) }, } var handlerEnableCmd = &cobra.Command{ - Use: "handler", + Use: "handler [...]", Aliases: []string{"handlers"}, Short: "Enables the given handlers", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.EnableHandler(args...) + client, ids := rootCmd.TakeAlias(args) + err := client.EnableHandler(ids...) printStatus(err) }, } diff --git a/cmd/client/command/get/get_cmd.go b/cmd/client/command/get/get_cmd.go index 8caa449..cdf0855 100644 --- a/cmd/client/command/get/get_cmd.go +++ b/cmd/client/command/get/get_cmd.go @@ -37,14 +37,15 @@ func init() { } var gwGetCmd = &cobra.Command{ - Use: "gateway", + Use: "gateway ", Aliases: []string{"gw", "gateways"}, Short: "Print the gateway details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -63,14 +64,15 @@ var gwGetCmd = &cobra.Command{ } var nodeGetCmd = &cobra.Command{ - Use: "node", + Use: "node ", Aliases: []string{"nodes"}, Short: "Print the node details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id", IsWide: true}, @@ -90,14 +92,15 @@ var nodeGetCmd = &cobra.Command{ } var sourceGetCmd = &cobra.Command{ - Use: "source", + Use: "source ", Aliases: []string{"sources"}, Short: "Print the source details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id", IsWide: true}, @@ -113,14 +116,15 @@ var sourceGetCmd = &cobra.Command{ } var fieldGetCmd = &cobra.Command{ - Use: "field", + Use: "field ", Aliases: []string{"fields"}, Short: "Print the field details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id", IsWide: true}, @@ -142,14 +146,15 @@ var fieldGetCmd = &cobra.Command{ } var firmwareGetCmd = &cobra.Command{ - Use: "firmware", + Use: "firmware ", Aliases: []string{"firmwares", "fw"}, Short: "Print the firmware details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -164,14 +169,15 @@ var firmwareGetCmd = &cobra.Command{ } var dataRepositoryGetCmd = &cobra.Command{ - Use: "data-repository", + Use: "data-repository ", Aliases: []string{"data-repositories", "data-repo"}, Short: "Print the data repository details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -185,14 +191,15 @@ var dataRepositoryGetCmd = &cobra.Command{ } var virtualDeviceGetCmd = &cobra.Command{ - Use: "virtual-device", + Use: "virtual-device ", Aliases: []string{"virtual-devices", "vd"}, Short: "Print the virtual device details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id", IsWide: true}, @@ -209,14 +216,15 @@ var virtualDeviceGetCmd = &cobra.Command{ } var virtualAssistantGetCmd = &cobra.Command{ - Use: "virtual-assistant", + Use: "virtual-assistant ", Aliases: []string{"virtual-assistants", "va"}, Short: "Print the virtual assistant details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -233,14 +241,15 @@ var virtualAssistantGetCmd = &cobra.Command{ } var taskGetCmd = &cobra.Command{ - Use: "task", + Use: "task ", Aliases: []string{"tasks"}, Short: "Print the task details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -259,14 +268,15 @@ var taskGetCmd = &cobra.Command{ } var scheduleGetCmd = &cobra.Command{ - Use: "schedule", + Use: "schedule ", Aliases: []string{"schedules"}, Short: "Print the schedule details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -282,14 +292,15 @@ var scheduleGetCmd = &cobra.Command{ } var handlerGetCmd = &cobra.Command{ - Use: "handler", + Use: "handler ", Aliases: []string{"handlers"}, Short: "Print the handler details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -305,14 +316,15 @@ var handlerGetCmd = &cobra.Command{ } var forwardPayloadGetCmd = &cobra.Command{ - Use: "forward-payload", + Use: "forward-payload ", Aliases: []string{"forward-payloads"}, Short: "Print the forward payload details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "id"}, @@ -327,14 +339,15 @@ var forwardPayloadGetCmd = &cobra.Command{ } var backupGetCmd = &cobra.Command{ - Use: "backup", + Use: "backup ", Aliases: []string{"backups"}, Short: "Print the backup details", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) headers := []printer.Header{ {Title: "filename", ValuePath: "id"}, diff --git a/cmd/client/command/get/settings_cmd.go b/cmd/client/command/get/settings_cmd.go new file mode 100644 index 0000000..a4e691d --- /dev/null +++ b/cmd/client/command/get/settings_cmd.go @@ -0,0 +1,152 @@ +package get + +import ( + "fmt" + "sort" + "strings" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + "github.com/mycontroller-org/server/v2/pkg/utils/printer" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func init() { + getCmd.AddCommand(settingsGetCmd) +} + +type settingsRow struct { + Key string + Value string +} + +var settingsGetCmd = &cobra.Command{ + Use: "settings [key-path]", + Aliases: []string{"setting"}, + Short: "Print system settings", + Long: `Print system settings. + +With no key path, lists all keys and values. +With a map key, lists all nested keys and values under it. +With a leaf key, prints only that key and value. + + myc get settings + myc get settings geoLocation + myc get settings geoLocation.latitude +`, + Example: ` myc get settings + myc get settings geoLocation + myc get settings language + myc get settings geoLocation.latitude -o yaml`, + Args: cobra.RangeArgs(1, 2), + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Run: func(cmd *cobra.Command, args []string) { + client := rootCmd.MustClient(args[0]) + settings, err := client.GetSystemSettings() + if err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + return + } + spec := settings.Spec + if spec == nil { + spec = map[string]interface{}{} + } + path := "" + if len(args) == 2 { + path = strings.TrimSpace(args[1]) + } + selected, err := lookupSettingsPath(spec, path) + if err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + return + } + printSettings(path, selected) + }, +} + +func printSettings(path string, selected interface{}) { + switch rootCmd.OutputFormat { + case printer.OutputYAML, printer.OutputJSON: + printer.Print(rootCmd.IOStreams.Out, nil, selected, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) + return + } + + headers := []printer.Header{ + {Title: "key", ValuePath: "Key"}, + {Title: "value", ValuePath: "Value"}, + } + if nested, ok := selected.(map[string]interface{}); ok { + printer.Print(rootCmd.IOStreams.Out, headers, flattenSettings(nested, path), rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) + return + } + printer.Print(rootCmd.IOStreams.Out, headers, []interface{}{settingsRow{Key: path, Value: formatSettingValue(selected)}}, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) +} + +func lookupSettingsPath(spec map[string]interface{}, path string) (interface{}, error) { + if path == "" { + return spec, nil + } + current := interface{}(spec) + for _, part := range strings.Split(path, ".") { + if part == "" { + return nil, fmt.Errorf("key path %q is not present", path) + } + nested, ok := current.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("key path %q is not present", path) + } + next, ok := nested[part] + if !ok { + return nil, fmt.Errorf("key path %q is not present", path) + } + current = next + } + return current, nil +} + +func flattenSettings(in map[string]interface{}, prefix string) []interface{} { + rows := make([]interface{}, 0) + for _, key := range sortedKeys(in) { + path := key + if prefix != "" { + path = prefix + "." + key + } + value := in[key] + if nested, ok := value.(map[string]interface{}); ok { + rows = append(rows, flattenSettings(nested, path)...) + continue + } + rows = append(rows, settingsRow{Key: path, Value: formatSettingValue(value)}) + } + return rows +} + +func formatSettingValue(value interface{}) string { + if value == nil { + return "" + } + if _, ok := value.([]interface{}); ok { + if raw, err := yaml.Marshal(value); err == nil { + return string(bytesTrimRightNewline(raw)) + } + } + return fmt.Sprint(value) +} + +func sortedKeys(in map[string]interface{}) []string { + keys := make([]string, 0, len(in)) + for key := range in { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func bytesTrimRightNewline(data []byte) []byte { + for len(data) > 0 && (data[len(data)-1] == '\n' || data[len(data)-1] == ' ') { + data = data[:len(data)-1] + } + return data +} diff --git a/cmd/client/command/get/settings_cmd_test.go b/cmd/client/command/get/settings_cmd_test.go new file mode 100644 index 0000000..4523de7 --- /dev/null +++ b/cmd/client/command/get/settings_cmd_test.go @@ -0,0 +1,60 @@ +package get + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleSettings() map[string]interface{} { + return map[string]interface{}{ + "language": "en", + "geoLocation": map[string]interface{}{ + "autoUpdate": true, + "locationName": "Berlin", + "latitude": 52.5, + }, + "login": map[string]interface{}{ + "message": "hello", + }, + } +} + +func TestLookupSettingsPathRoot(t *testing.T) { + spec := sampleSettings() + got, err := lookupSettingsPath(spec, "") + require.NoError(t, err) + nested, ok := got.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, []string{"geoLocation", "language", "login"}, sortedKeys(nested)) +} + +func TestLookupSettingsPathMap(t *testing.T) { + got, err := lookupSettingsPath(sampleSettings(), "geoLocation") + require.NoError(t, err) + nested := got.(map[string]interface{}) + assert.Equal(t, true, nested["autoUpdate"]) + assert.Equal(t, 52.5, nested["latitude"]) +} + +func TestLookupSettingsPathLeaf(t *testing.T) { + got, err := lookupSettingsPath(sampleSettings(), "geoLocation.latitude") + require.NoError(t, err) + assert.Equal(t, 52.5, got) +} + +func TestLookupSettingsPathMissing(t *testing.T) { + _, err := lookupSettingsPath(sampleSettings(), "geoLocation.missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "not present") +} + +func TestFlattenSettingsUnderPrefix(t *testing.T) { + rows := flattenSettings(sampleSettings()["geoLocation"].(map[string]interface{}), "geoLocation") + require.Len(t, rows, 3) + assert.Equal(t, "geoLocation.autoUpdate", rows[0].(settingsRow).Key) + assert.Equal(t, "true", rows[0].(settingsRow).Value) + assert.Equal(t, "geoLocation.latitude", rows[1].(settingsRow).Key) + assert.Equal(t, "52.5", rows[1].(settingsRow).Value) +} diff --git a/cmd/client/command/reboot/reboot_cmd.go b/cmd/client/command/reboot/reboot_cmd.go index 0bd4727..404a062 100644 --- a/cmd/client/command/reboot/reboot_cmd.go +++ b/cmd/client/command/reboot/reboot_cmd.go @@ -13,19 +13,19 @@ func init() { } var nodeRebootCmd = &cobra.Command{ - Use: "node [...]", + Use: "node [...]", Aliases: []string{"nodes"}, Short: "Reboot one or more nodes", - Example: ` myc reboot node mysensor.1 mysensor.2`, + Example: ` myc reboot node mysensor.1 mysensor.2`, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - client := rootCmd.GetClient() - ids, resolveErr := client.ResolveNodeIDs(args) + client, rest := rootCmd.TakeAlias(args) + ids, resolveErr := client.ResolveNodeIDs(rest) if len(ids) > 0 { if err := client.ExecuteNodeAction(nodeTY.ActionReboot, ids); err != nil { return err diff --git a/cmd/client/command/reload/reload_cmd.go b/cmd/client/command/reload/reload_cmd.go index 26b0f03..1da6835 100644 --- a/cmd/client/command/reload/reload_cmd.go +++ b/cmd/client/command/reload/reload_cmd.go @@ -13,17 +13,17 @@ func init() { } var gwReloadCmd = &cobra.Command{ - Use: "gateway [...]", + Use: "gateway [...]", Aliases: []string{"gw", "gateways"}, Short: "Reload one or more gateways", - Example: ` myc reload gateway mysensor gw2`, + Example: ` myc reload gateway mysensor gw2`, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - ids, resolveErr := client.ResolveGatewayIDs(args, false) + client, rest := rootCmd.TakeAlias(args) + ids, resolveErr := client.ResolveGatewayIDs(rest, false) if len(ids) > 0 { err := client.ReloadGateway(ids...) printStatus(err, len(ids), "gateway") @@ -38,15 +38,16 @@ var gwReloadCmd = &cobra.Command{ } var virtualAssistantReloadCmd = &cobra.Command{ - Use: "virtual-assistant [...]", + Use: "virtual-assistant [...]", Aliases: []string{"virtual-assistants", "va"}, Short: "Reload one or more virtual assistants", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - err := rootCmd.GetClient().ReloadVirtualAssistant(args...) - printStatus(err, len(args), "virtual assistant") + client, ids := rootCmd.TakeAlias(args) + err := client.ReloadVirtualAssistant(ids...) + printStatus(err, len(ids), "virtual assistant") }, } diff --git a/cmd/client/command/root/cmd.go b/cmd/client/command/root/cmd.go index db93b71..2486e25 100644 --- a/cmd/client/command/root/cmd.go +++ b/cmd/client/command/root/cmd.go @@ -9,6 +9,7 @@ import ( "github.com/mycontroller-org/server/v2/cmd/client/api" clientTY "github.com/mycontroller-org/server/v2/pkg/types/client" printer "github.com/mycontroller-org/server/v2/pkg/utils/printer" + "github.com/mycontroller-org/server/v2/pkg/version" "gopkg.in/yaml.v3" "github.com/spf13/cobra" @@ -17,15 +18,15 @@ import ( const ( ENV_PREFIX = "MYC" + ENV_CONFIG = "MYC_CONFIG" CONFIG_FILE_NAME = ".mycontroller" CONFIG_FILE_EXT = "yaml" ) var ( - cfgFile string - CONFIG *clientTY.Config // keep MyController server details - IOStreams clientTY.IOStreams // read and write to this stream - + cfgFile string + CONFIG *clientTY.Config + IOStreams clientTY.IOStreams HideHeader bool Pretty bool OutputFormat string @@ -48,16 +49,53 @@ var Cmd = &cobra.Command{ func init() { CONFIG = &clientTY.Config{} + v := version.Get() + Cmd.Version = v.Version + Cmd.SetVersionTemplate(formatClientVersion(v)) + cobra.OnInitialize(initConfig) - Cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mycontroller.yaml)") + Cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $MYC_CONFIG or $HOME/.mycontroller.yaml)") Cmd.PersistentFlags().StringVarP(&OutputFormat, "output", "o", printer.OutputConsole, "output format. options: yaml, json, console, wide") Cmd.PersistentFlags().BoolVar(&HideHeader, "hide-header", false, "hides the header on the console output") Cmd.PersistentFlags().BoolVar(&Pretty, "pretty", false, "JSON pretty print") } -func GetClient() *api.Client { - return api.NewClient(CONFIG.URL, CONFIG.GetPassword(), CONFIG.Insecure) +func LookupClient(name string) (*api.Client, error) { + if CONFIG == nil { + CONFIG = &clientTY.Config{} + } + CONFIG.EnsureAliases() + if name == "" { + return nil, fmt.Errorf("alias is required") + } + alias, ok := CONFIG.Aliases[name] + if !ok { + return nil, fmt.Errorf("alias %q is not configured", name) + } + return api.NewClient(alias.URL, alias.GetPassword(), alias.Insecure), nil +} + +func MustClient(name string) *api.Client { + client, err := LookupClient(name) + if err != nil { + _, _ = fmt.Fprintln(IOStreams.ErrOut, err) + os.Exit(1) + } + return client +} + +func TakeAlias(args []string) (*api.Client, []string) { + if len(args) < 1 { + _, _ = fmt.Fprintln(IOStreams.ErrOut, "alias is required") + os.Exit(1) + } + return MustClient(args[0]), args[1:] +} + +func formatClientVersion(v version.Version) string { + return fmt.Sprintf("version: %s\nbuild date: %s\ngit commit: %s\ngolang: %s\nplatform: %s\narch: %s\n", + v.Version, v.BuildDate, v.GitCommit, v.GoVersion, v.Platform, v.Arch) } func UpdateStreams(cmd *cobra.Command) { @@ -80,8 +118,7 @@ func WriteConfigFile() { if CONFIG == nil { CONFIG = &clientTY.Config{} } - // encode password field - CONFIG.EncodePassword() + CONFIG.EncodePasswords() configBytes, err := yaml.Marshal(CONFIG) if err != nil { @@ -94,21 +131,18 @@ func WriteConfigFile() { } func initConfig() { + if cfgFile == "" { + cfgFile = os.Getenv(ENV_CONFIG) + } if cfgFile != "" { - // Use config file from the flag. viper.SetConfigFile(cfgFile) } else { - // Find home directory.initConfig home, err := homedir.Dir() cobra.CheckErr(err) - - // Search config in home directory with name ".myc" (without extension). viper.AddConfigPath(home) viper.SetConfigName(CONFIG_FILE_NAME) viper.SetConfigType(CONFIG_FILE_EXT) - cfgFile = filepath.Join(home, fmt.Sprintf("%s.%s", CONFIG_FILE_NAME, CONFIG_FILE_EXT)) - } viper.SetEnvPrefix(ENV_PREFIX) @@ -120,4 +154,8 @@ func initConfig() { _, _ = fmt.Fprint(IOStreams.ErrOut, "error on unmarshal of config\n", err) } } + if CONFIG == nil { + CONFIG = &clientTY.Config{} + } + CONFIG.EnsureAliases() } diff --git a/cmd/client/command/root/login.go b/cmd/client/command/root/login.go deleted file mode 100644 index 7734aed..0000000 --- a/cmd/client/command/root/login.go +++ /dev/null @@ -1,136 +0,0 @@ -package root - -import ( - "fmt" - "os" - "time" - - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var ( - loginUsername string - loginPassword string - loginToken string - loginExpiresIn string - loginInsecure bool -) - -func init() { - Cmd.AddCommand(loginCmd) - loginCmd.Flags().StringVarP(&loginUsername, "username", "u", "", "Username to login") - loginCmd.Flags().StringVarP(&loginPassword, "password", "p", "", "Password to login") - loginCmd.Flags().StringVarP(&loginToken, "token", "t", "", "token to login") - loginCmd.Flags().StringVar(&loginExpiresIn, "expires-in", "720h", "session expires in, value in hours") - loginCmd.Flags().BoolVar(&loginInsecure, "insecure", false, - "If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure") - - Cmd.AddCommand(logoutCmd) -} - -var loginCmd = &cobra.Command{ - Use: "login", - Short: "Log in to a MyController server", - Example: ` # login into the MyController server with username and password - myc login http://localhost:8080 --username admin --password password - - # login into the MyController insecure server (with SSL certificate) - myc login https://localhost:8443 --username admin --password password --insecure - - # prompt username and password - myc login http://localhost:8080 - - # prompt password - myc login http://localhost:8080 --username admin - - # token based login - myc login http://localhost:8080 --token - `, - PreRun: func(cmd *cobra.Command, args []string) { - UpdateStreams(cmd) - }, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if loginToken == "" { - // get username from terminal - if loginUsername == "" { - _username, err := promptUsername() - if err != nil { - _, _ = fmt.Fprintln(IOStreams.ErrOut, err.Error()) - return - } - loginUsername = _username - } - - // get password from terminal - if loginPassword == "" { - _password, err := promptPassword() - if err != nil { - _, _ = fmt.Fprintln(IOStreams.ErrOut, err.Error()) - return - } - loginPassword = _password - } - } - - CONFIG.URL = args[0] - CONFIG.Insecure = loginInsecure - client := GetClient() - res, err := client.Login(loginUsername, loginPassword, loginToken, loginExpiresIn) - if err != nil { - _, _ = fmt.Fprintln(IOStreams.ErrOut, "error on login", err) - return - } - if res != nil { - _, _ = fmt.Fprintln(IOStreams.ErrOut, "Login successful.") - CONFIG.URL = args[0] - CONFIG.Username = loginUsername - CONFIG.Password = res.Token - CONFIG.Insecure = loginInsecure - CONFIG.LoginTime = time.Now().Format(time.RFC3339) - CONFIG.ExpiresIn = loginExpiresIn - WriteConfigFile() - } - }, -} - -var logoutCmd = &cobra.Command{ - Use: "logout", - Short: "Log out from a server", - Example: ` # logout from a server - mc logout`, - Run: func(cmd *cobra.Command, args []string) { - if CONFIG.URL == "" { - _, _ = fmt.Fprintln(IOStreams.ErrOut, "There is no connection information.") - return - } - CONFIG.URL = "" - CONFIG.Username = "" - CONFIG.Password = "" - CONFIG.Insecure = false - _, _ = fmt.Fprintln(IOStreams.Out, "Logout successful.") - WriteConfigFile() - }, -} - -func promptUsername() (string, error) { - var username string - _, err := fmt.Fprint(IOStreams.Out, "Username: ") - if err != nil { - return username, err - } - _, err = fmt.Fscanln(IOStreams.In, &username) - return username, err -} - -func promptPassword() (string, error) { - _, _ = fmt.Fprint(IOStreams.Out, "Password: ") - // TODO: should use IOStreams.In in the place of os.Stdin.Fd - pw, err := term.ReadPassword(int(os.Stdin.Fd())) - _, _ = fmt.Fprintln(IOStreams.Out) - if err != nil { - return "", err - } - return string(pw), nil -} diff --git a/cmd/client/command/root/version.go b/cmd/client/command/root/version.go deleted file mode 100644 index fb9710f..0000000 --- a/cmd/client/command/root/version.go +++ /dev/null @@ -1,76 +0,0 @@ -package root - -import ( - "fmt" - - "github.com/mycontroller-org/server/v2/pkg/utils/printer" - "github.com/mycontroller-org/server/v2/pkg/version" - - "github.com/spf13/cobra" -) - -func init() { - Cmd.AddCommand(versionCmd) -} - -type VersionMap struct { - Spec map[string]interface{} `json:"spec"` -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the client and server version information", - PreRun: func(cmd *cobra.Command, args []string) { - UpdateStreams(cmd) - }, - Run: func(cmd *cobra.Command, args []string) { - headers := []printer.Header{ - {Title: "component", ValuePath: "spec.type"}, - {Title: "version", ValuePath: "spec.version"}, - {Title: "build date", ValuePath: "spec.buildDate"}, - {Title: "git commit", ValuePath: "spec.gitCommit"}, - {Title: "golang", ValuePath: "spec.goLang"}, - {Title: "platform", ValuePath: "spec.platform"}, - {Title: "arch", ValuePath: "spec.arch"}, - {Title: "host id", ValuePath: "spec.hostId"}, - } - - rows := make([]interface{}, 0) - - // client version details - clientVersion := version.Get() - clientRow := map[string]interface{}{ - "type": "client", - "version": clientVersion.Version, - "buildDate": clientVersion.BuildDate, - "gitCommit": clientVersion.GitCommit, - "goLang": clientVersion.GoVersion, - "platform": clientVersion.Platform, - "arch": clientVersion.Arch, - "hostId": clientVersion.HostID, - } - rows = append(rows, VersionMap{Spec: clientRow}) - - serverRow := map[string]interface{}{"type": "server"} - if CONFIG.URL == "" { - serverRow["version"] = "not logged in" - } else { - client := GetClient() - serverVersion, err := client.GetServerVersion() - if err != nil { - serverRow["version"] = fmt.Sprintf("error:%s", err) - } else { - serverRow["version"] = serverVersion.Version - serverRow["buildDate"] = serverVersion.BuildDate - serverRow["gitCommit"] = serverVersion.GitCommit - serverRow["goLang"] = serverVersion.GoVersion - serverRow["platform"] = serverVersion.Platform - serverRow["arch"] = serverVersion.Arch - serverRow["hostId"] = serverVersion.HostID - } - } - rows = append(rows, VersionMap{Spec: serverRow}) - - printer.Print(IOStreams.Out, headers, rows, HideHeader, OutputFormat, Pretty) - }, -} diff --git a/cmd/client/command/server/cmd.go b/cmd/client/command/server/cmd.go new file mode 100644 index 0000000..d018b79 --- /dev/null +++ b/cmd/client/command/server/cmd.go @@ -0,0 +1,65 @@ +package server + +import ( + "fmt" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + "github.com/mycontroller-org/server/v2/pkg/utils/printer" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.Cmd.AddCommand(serverCmd) + serverCmd.AddCommand(infoCmd) +} + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Show server information", + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, +} + +type infoRow struct { + Field string + Value string +} + +var infoCmd = &cobra.Command{ + Use: "info ", + Short: "Print version details of a MyController server", + Example: ` myc server info `, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := rootCmd.LookupClient(args[0]) + if err != nil { + return err + } + info, err := client.GetServerVersion() + if err != nil { + return fmt.Errorf("failed to get server info: %w", err) + } + headers := []printer.Header{ + {Title: "field", ValuePath: "Field"}, + {Title: "value", ValuePath: "Value"}, + } + rows := []interface{}{ + infoRow{"alias", args[0]}, + infoRow{"version", info.Version}, + infoRow{"build date", info.BuildDate}, + infoRow{"git commit", info.GitCommit}, + infoRow{"golang", info.GoVersion}, + infoRow{"platform", info.Platform}, + infoRow{"arch", info.Arch}, + infoRow{"host id", info.HostID}, + } + printer.Print(rootCmd.IOStreams.Out, headers, rows, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) + return nil + }, +} diff --git a/cmd/client/command/set/cmd.go b/cmd/client/command/set/cmd.go index 6aeb2c7..3dc5447 100644 --- a/cmd/client/command/set/cmd.go +++ b/cmd/client/command/set/cmd.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "github.com/mycontroller-org/server/v2/cmd/client/api" rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" webHandlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" "github.com/spf13/cobra" @@ -33,20 +34,19 @@ var setCmd = &cobra.Command{ Short: "Set a nested property on a resource, or a live field value", Long: `Update a nested property (scripts and other text) on a resource. - myc set field gw1.1.1.V_CUSTOM formatter.onReceive --file on_receive.js - myc set data-repository ota_stm32_ab data.onConfig --file onConfig.js + myc set field gw1.1.1.V_CUSTOM formatter.onReceive --file on_receive.js + myc set data-repository ota_stm32_ab data.onConfig --file onConfig.js Set a live field value with a separate command: - myc set value field gw1.1.1.V_CUSTOM 23.5 + myc set value field gw1.1.1.V_CUSTOM 23.5 `, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, } -func executeSetFieldValue(resources []string, payload string) error { - client := rootCmd.GetClient() +func executeSetFieldValue(client *api.Client, resources []string, payload string) error { actions := make([]webHandlerTY.ActionConfig, 0, len(resources)) for _, resource := range resources { actions = append(actions, webHandlerTY.ActionConfig{ @@ -63,8 +63,7 @@ func executeSetFieldValue(resources []string, payload string) error { return nil } -func executeSetPath(kind string, selectors []string, keyPath, value string) error { - client := rootCmd.GetClient() +func executeSetPath(client *api.Client, kind string, selectors []string, keyPath, value string) error { failed := 0 for _, selector := range selectors { if err := client.SetResourcePath(kind, selector, keyPath, value, setFile != ""); err != nil { diff --git a/cmd/client/command/set/parse_test.go b/cmd/client/command/set/parse_test.go index 72054cd..9f921fa 100644 --- a/cmd/client/command/set/parse_test.go +++ b/cmd/client/command/set/parse_test.go @@ -61,6 +61,21 @@ func TestParseSetArgsKeyPathWithoutValue(t *testing.T) { assert.Contains(t, err.Error(), "formatter.onReceive") } +func TestParseSettingsArgs(t *testing.T) { + path, value, err := parseSettingsArgs([]string{"language", "en"}, "", "") + require.NoError(t, err) + assert.Equal(t, "language", path) + assert.Equal(t, "en", value) + + _, _, err = parseSettingsArgs([]string{"geoLocation.autoUpdate"}, "", "") + require.Error(t, err) + + path, value, err = parseSettingsArgs([]string{"true"}, "", "geoLocation.autoUpdate") + require.NoError(t, err) + assert.Equal(t, "geoLocation.autoUpdate", path) + assert.Equal(t, "true", value) +} + func TestParseSetArgsBareNameRequiresValue(t *testing.T) { _, _, _, err := parseSetArgs([]string{"gw1.1.1.V_CUSTOM", "name"}, "", "") require.Error(t, err) diff --git a/cmd/client/command/set/set_cmd.go b/cmd/client/command/set/set_cmd.go index 246b2ab..89f8a7c 100644 --- a/cmd/client/command/set/set_cmd.go +++ b/cmd/client/command/set/set_cmd.go @@ -1,6 +1,7 @@ package set import ( + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" "github.com/spf13/cobra" ) @@ -16,7 +17,7 @@ func init() { func newSetResourceCmd(use string, aliases []string, kind string) *cobra.Command { return &cobra.Command{ - Use: use + " [value]", + Use: use + " [value]", Aliases: aliases, Short: "Set a nested field on " + use + " resource(s)", Long: `Update a nested property on one or more resources. @@ -26,47 +27,48 @@ The value can be given as the last argument or read from --file. To set a live field value, use myc set value field. `, Example: setExamples(use), - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(2), SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - selectors, keyPath, value, err := parseSetArgs(args, setFile, setPath) + client, rest := rootCmd.TakeAlias(args) + selectors, keyPath, value, err := parseSetArgs(rest, setFile, setPath) if err != nil { return err } - return executeSetPath(kind, selectors, keyPath, value) + return executeSetPath(client, kind, selectors, keyPath, value) }, } } func setExamples(use string) string { - examples := ` myc set ` + use + ` description "updated from cli" - myc set ` + use + ` data.onConfig --file onConfig.js` + examples := ` myc set ` + use + ` description "updated from cli" + myc set ` + use + ` data.onConfig --file onConfig.js` if use == "field" { - examples = ` myc set field mysensor.1.dht.temperature formatter.onReceive --file on_receive.js - myc set field mysensor.1.dht.temperature formatter.onReceive "return value;" - myc set field --path formatter.onReceive --file on_receive.js - myc set field gw1.1.1.V_CUSTOM name "Custom"` + examples = ` myc set field mysensor.1.dht.temperature formatter.onReceive --file on_receive.js + myc set field mysensor.1.dht.temperature formatter.onReceive "return value;" + myc set field --path formatter.onReceive --file on_receive.js + myc set field gw1.1.1.V_CUSTOM name "Custom"` } if use == "gateway" { - examples = ` myc set gateway mysensor description "USB gateway" - myc set gateway mysensor provider.protocol.script --file script.js` + examples = ` myc set gateway mysensor description "USB gateway" + myc set gateway mysensor provider.protocol.script --file script.js` } if use == "node" { - examples = ` myc set node mysensor.1 name "Living Room" - myc set node others.note --file note.txt` + examples = ` myc set node mysensor.1 name "Living Room" + myc set node others.note --file note.txt` } if use == "source" { - examples = ` myc set source mysensor.1.dht name "DHT" - myc set source others.script --file script.js` + examples = ` myc set source mysensor.1.dht name "DHT" + myc set source others.script --file script.js` } if use == "firmware" { - examples = ` myc set firmware stm32-app-slot-a description "slot A" - myc set firmware stm32-app-slot-a labels.ms_flash_slot A` + examples = ` myc set firmware stm32-app-slot-a description "slot A" + myc set firmware stm32-app-slot-a labels.ms_flash_slot A` } if use == "data-repository" { - examples = ` myc set data-repository ota_stm32_ab data.onConfig --file onConfig.js - myc set data-repo ota_stm32_ab data.onBlock --file onBlock.js` + examples = ` myc set data-repository ota_stm32_ab data.onConfig --file onConfig.js + myc set data-repo ota_stm32_ab data.onBlock --file onBlock.js` } return examples } diff --git a/cmd/client/command/set/settings_cmd.go b/cmd/client/command/set/settings_cmd.go new file mode 100644 index 0000000..0be1cf7 --- /dev/null +++ b/cmd/client/command/set/settings_cmd.go @@ -0,0 +1,94 @@ +package set + +import ( + "fmt" + "os" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func init() { + setCmd.AddCommand(settingsSetCmd) +} + +var settingsSetCmd = &cobra.Command{ + Use: "settings [ [value]]", + Aliases: []string{"setting"}, + Short: "Update system settings", + Long: `Update system settings on a server. + +A key path is relative to the settings spec, for example language or geoLocation.latitude. +Use --file to read a single value or, without a key path, to merge a YAML/JSON object. + + myc set settings language en + myc set settings geoLocation.autoUpdate true + myc set settings login.message --file message.txt + myc set settings --file settings.yaml +`, + Example: ` myc set settings language en + myc set settings geoLocation.latitude 12.97 + myc set settings --file settings.yaml`, + Args: cobra.MinimumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + client := rootCmd.MustClient(args[0]) + rest := args[1:] + if setFile != "" && len(rest) == 0 && setPath == "" { + data, err := os.ReadFile(setFile) + if err != nil { + return fmt.Errorf("failed to read %s: %w", setFile, err) + } + var overlay map[string]interface{} + if err := yaml.Unmarshal(data, &overlay); err != nil { + return fmt.Errorf("failed to parse %s: %w", setFile, err) + } + if err := client.MergeSystemSettings(overlay); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "set settings: merged %s\n", setFile) + return nil + } + keyPath, value, err := parseSettingsArgs(rest, setFile, setPath) + if err != nil { + return err + } + if err := client.SetSystemSettingsPath(keyPath, value, setFile != ""); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "set settings: %s\n", keyPath) + return nil + }, +} + +func parseSettingsArgs(args []string, file, path string) (string, string, error) { + if path != "" { + if file != "" { + value, err := readSetValue(file, "") + return path, value, err + } + if len(args) < 1 { + return "", "", fmt.Errorf("value or --file is required for key path %s", path) + } + return path, args[0], nil + } + if file != "" { + if len(args) < 1 { + return "", "", fmt.Errorf("key path is required") + } + value, err := readSetValue(file, "") + return args[0], value, err + } + if len(args) == 1 { + return "", "", fmt.Errorf("value or --file is required for key path %s", args[0]) + } + if len(args) != 2 { + return "", "", fmt.Errorf("key path and value are required (or use --file)") + } + return args[0], args[1], nil +} diff --git a/cmd/client/command/set/value_cmd.go b/cmd/client/command/set/value_cmd.go index 8d38b4e..a5ac875 100644 --- a/cmd/client/command/set/value_cmd.go +++ b/cmd/client/command/set/value_cmd.go @@ -3,6 +3,7 @@ package set import ( "fmt" + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" "github.com/spf13/cobra" ) @@ -17,19 +18,20 @@ func init() { } var valueFieldCmd = &cobra.Command{ - Use: "field [quick-id...] ", + Use: "field [quick-id...] ", Aliases: []string{"fields"}, Short: "Set a live field value", - Example: ` myc set value field gw1.1.1.V_CUSTOM 23.5 - myc set value field mysensor.1.dht.temperature 21.0`, - Args: cobra.MinimumNArgs(2), + Example: ` myc set value field gw1.1.1.V_CUSTOM 23.5 + myc set value field mysensor.1.dht.temperature 21.0`, + Args: cobra.MinimumNArgs(3), SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { if setFile != "" || setPath != "" { return fmt.Errorf("myc set value field does not use --file or --path; use myc set field to update stored properties") } - payload := args[len(args)-1] - return executeSetFieldValue(args[:len(args)-1], payload) + client, rest := rootCmd.TakeAlias(args) + payload := rest[len(rest)-1] + return executeSetFieldValue(client, rest[:len(rest)-1], payload) }, } diff --git a/cmd/client/command/upload/cmd.go b/cmd/client/command/upload/cmd.go index 08deb0c..9fe0577 100644 --- a/cmd/client/command/upload/cmd.go +++ b/cmd/client/command/upload/cmd.go @@ -25,28 +25,28 @@ var uploadCmd = &cobra.Command{ } var firmwareUploadCmd = &cobra.Command{ - Use: "firmware ", + Use: "firmware ", Aliases: []string{"fw"}, Short: "Upload a firmware binary to an existing firmware resource", Long: `Upload a firmware binary to an existing firmware resource. Create the firmware metadata first with myc apply, then upload the file: - myc apply -f firmware.yaml - myc upload firmware stm32-app ./app.signed.bin + myc apply -f firmware.yaml + myc upload firmware stm32-app ./app.signed.bin `, - Example: ` myc upload firmware stm32-app ./app.signed.bin - myc upload fw stm32-app ./app.bin`, + Example: ` myc upload firmware stm32-app ./app.signed.bin + myc upload fw stm32-app ./app.bin`, SilenceUsage: true, SilenceErrors: true, - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(3), PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { - id := args[0] - filename := args[1] - client := rootCmd.GetClient() + client := rootCmd.MustClient(args[0]) + id := args[1] + filename := args[2] existing, err := client.FindFirmware(id) if err != nil { return fmt.Errorf("failed to look up firmware %s: %w", id, err) diff --git a/cmd/client/main.go b/cmd/client/main.go index 36be5f5..76828b6 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -5,6 +5,7 @@ import ( clientTY "github.com/mycontroller-org/server/v2/pkg/types/client" _ "github.com/mycontroller-org/server/v2/cmd/client/command/action" + _ "github.com/mycontroller-org/server/v2/cmd/client/command/alias" _ "github.com/mycontroller-org/server/v2/cmd/client/command/apply" _ "github.com/mycontroller-org/server/v2/cmd/client/command/delete" _ "github.com/mycontroller-org/server/v2/cmd/client/command/disable" @@ -12,6 +13,7 @@ import ( _ "github.com/mycontroller-org/server/v2/cmd/client/command/get" _ "github.com/mycontroller-org/server/v2/cmd/client/command/reboot" _ "github.com/mycontroller-org/server/v2/cmd/client/command/reload" + _ "github.com/mycontroller-org/server/v2/cmd/client/command/server" _ "github.com/mycontroller-org/server/v2/cmd/client/command/set" _ "github.com/mycontroller-org/server/v2/cmd/client/command/upload" ) diff --git a/docs/cli.md b/docs/cli.md index 7e4fb1f..29b12ea 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # MyController CLI (`myc`) -This document describes the **MyController command-line client**: how to build it, log in, list and change resources, and apply gateways, nodes, sources, and fields from YAML or JSON files. +This document describes the **MyController command-line client**: how to build it, configure aliases, list and change resources, and apply gateways, nodes, sources, and fields from YAML or JSON files. --- @@ -10,8 +10,8 @@ This document describes the **MyController command-line client**: how to build i | Command | Purpose | | --- | --- | -| `login` / `logout` | Store or clear server credentials | -| `version` | Print client and server version | +| `alias` | Add, list, or remove named server connections | +| `server` | Show server information for an alias | | `get` | List resources | | `apply` | Add, merge, or delete resources from a YAML or JSON file | | `upload` | Upload a firmware binary to an existing firmware resource | @@ -37,49 +37,58 @@ go build -trimpath -o builds/myc ./cmd/client --- -## 2. Configuration +## 2. Configuration and aliases -After a successful login, `myc` writes `$HOME/.mycontroller.yaml` (override with `--config`). +`myc` stores named connections (aliases) in a YAML config file. Each alias is a server URL plus a logged-in user session. Use this to talk to more than one server, or as more than one user. -Environment variables use the prefix `MYC_` (Viper automatic env). Example: `MYC_URL`. +Config file location, in order: `--config`, then `$MYC_CONFIG`, then `$HOME/.mycontroller.yaml`. + +There is **no default alias**. Every server command takes the alias as its first argument. + +```yaml +aliases: + : + url: http://localhost:8080 + username: admin + password: BASE64/... + insecure: false + expiresIn: 720h +``` The stored password field is the session token, encoded as `BASE64/...`. ### Global flags -These flags apply to every command: - | Flag | Default | Description | | --- | --- | --- | -| `--config` | `$HOME/.mycontroller.yaml` | Client config file | +| `--config` | `$MYC_CONFIG` or `$HOME/.mycontroller.yaml` | Client config file | +| `--version` | | Print full client version details (no alias required) | | `-o`, `--output` | `console` | Output format: `console`, `wide`, `yaml`, `json` | | `--hide-header` | `false` | Hide table headers on console output | | `--pretty` | `false` | Pretty-print JSON | -`wide` is the same as `console` plus extra columns marked as wide (for example quick id). - --- -## 3. Login and logout +## 3. Alias ```bash -# username and password -myc login http://localhost:8080 --username admin --password password +# add an alias and log in (prompts for username and password) +myc alias set http://localhost:8080 -# prompt for username and password -myc login http://localhost:8080 +# with credentials on the command line +myc alias set http://localhost:8080 -u admin -p password -# prompt for password only -myc login http://localhost:8080 --username admin - -# service token -myc login http://localhost:8080 --token +# token +myc alias set http://localhost:8080 --token # TLS without certificate verification -myc login https://localhost:8443 --username admin --password password --insecure +myc alias set https://mc.example.com -u admin --insecure + +myc alias list +myc alias remove ``` -| Flag | Default | Description | +| Flag (`alias set`) | Default | Description | | --- | --- | --- | | `-u`, `--username` | | Login username | | `-p`, `--password` | | Login password | @@ -87,15 +96,23 @@ myc login https://localhost:8443 --username admin --password password --insecure | `--expires-in` | `720h` | Session lifetime | | `--insecure` | `false` | Skip TLS certificate verification | +Every server command names the alias: + ```bash -myc logout +myc get node +myc apply -f resources.yaml +myc server info ``` +Client version (no alias): + ```bash -myc version +myc --version ``` -Prints client build information. If logged in, also queries the server version. +Prints version, build date, git commit, Go version, platform, and arch. + +Alias names start with a letter and may contain letters, numbers, `-`, and `_`. Command names such as `get` and `apply` are reserved. --- @@ -104,15 +121,21 @@ Prints client build information. If logged in, also queries the server version. List resources from the server. ```bash -myc get gateway -myc get node -myc get source --limit 50 --sort-by name --sort-order desc -myc get field --filter "gateway id=mysensor" --filter "node id==1" -myc get gateway -o yaml -myc get node -o json --pretty -myc get field -o wide +myc get gateway +myc get node +myc get source --limit 50 --sort-by name --sort-order desc +myc get field --filter "gateway id=mysensor" --filter "node id==1" +myc get gateway -o yaml +myc get node -o json --pretty +myc get field -o wide +myc get settings +myc get settings geoLocation +myc get settings geoLocation.latitude +myc get settings -o yaml ``` +With no key path, `get settings` lists all keys and values. A map key lists all nested keys and values under it. A leaf key prints only that key and value. + ### Persistent flags | Flag | Default | Description | @@ -165,11 +188,11 @@ The key is matched against the table header title (spaces ignored, case insensit Firmware **binaries** are not part of apply. Create the firmware resource with apply, then upload the file with `myc upload firmware`. ```bash -myc apply -f resources.yaml -myc apply -f resources.yaml --dry-run -myc apply -f resources.yaml --replace -myc apply -f nodes.yaml -f sources.yaml -myc apply -f - --dry-run < resources.json +myc apply -f resources.yaml +myc apply -f resources.yaml --dry-run +myc apply -f resources.yaml --replace +myc apply -f nodes.yaml -f sources.yaml +myc apply -f - --dry-run < resources.json ``` | Flag | Description | @@ -513,7 +536,7 @@ data: Verify first: ```bash -myc apply -f resources.yaml --dry-run +myc apply -f resources.yaml --dry-run ``` Then apply. Use `--replace` when you want existing `add` targets recreated instead of failing. @@ -525,9 +548,9 @@ Then apply. Use `--replace` when you want existing `add` targets recreated inste Upload a binary to an **existing** firmware resource. Apply the firmware metadata first. ```bash -myc apply -f firmware.yaml -myc upload firmware stm32-app-slot-a ./app-slot-a.signed.bin -myc upload fw stm32-app-slot-a ./app-slot-a.signed.bin +myc apply -f firmware.yaml +myc upload firmware stm32-app-slot-a ./app-slot-a.signed.bin +myc upload fw stm32-app-slot-a ./app-slot-a.signed.bin ``` | Argument | Description | @@ -564,9 +587,9 @@ firmware stm32-app-slot-a is not present ### Nested property (scripts and other text) ```bash -myc set -myc set --file script.js -myc set --path --file script.js +myc set +myc set --file script.js +myc set --path --file script.js ``` | Kind | Aliases | Id | @@ -591,19 +614,41 @@ The key path uses dots and matches JSON field names: `--file` always stores the file contents as raw text (useful for JavaScript). Without `--file`, the last argument is the value. Inline values that are valid JSON (`true`, `false`, numbers, objects, arrays) are stored as that type; other inline text is stored as a string. ```bash -myc set field mysensor.1.dht.temperature formatter.onReceive --file on_receive.js -myc set field mysensor.1.dht.temperature formatter.onReceive "return value;" -myc set data-repository ota_stm32_ab data.onConfig --file onConfig.js -myc set data-repo ota_stm32_ab --path data.onBlock --file onBlock.js -myc set gateway mysensor description "USB gateway" -myc set node mysensor.1 others.note --file note.txt -myc set firmware stm32-app-slot-a labels.ms_flash_slot A +myc set field mysensor.1.dht.temperature formatter.onReceive --file on_receive.js +myc set field mysensor.1.dht.temperature formatter.onReceive "return value;" +myc set data-repository ota_stm32_ab data.onConfig --file onConfig.js +myc set data-repo --path data.onBlock --file onBlock.js +myc set gateway mysensor description "USB gateway" +myc set node mysensor.1 others.note --file note.txt +myc set firmware stm32-app-slot-a labels.ms_flash_slot A ``` Several ids can be given; they all receive the same path and value: ```bash -myc set field id-1 id-2 formatter.onReceive --file on_receive.js +myc set field id-1 id-2 formatter.onReceive --file on_receive.js +``` + +### System settings + +Paths are relative to the settings spec. Nested maps are merged; keys not in the update stay as they are. + +```bash +myc get settings +myc set settings language en +myc set settings geoLocation.autoUpdate true +myc set settings geoLocation.latitude 12.97 +myc set settings login.message --file message.txt +myc set settings --file settings.yaml +``` + +`--file` without a key path merges a YAML/JSON object into the spec: + +```yaml +language: en +geoLocation: + autoUpdate: true + locationName: Berlin ``` ### Live field value @@ -611,8 +656,8 @@ myc set field id-1 id-2 formatter.onReceive --file on_receive.js Use `set value field`. This sends an action; it does not change stored metadata. ```bash -myc set value field gw1.1.1.V_CUSTOM 23.5 -myc set value field mysensor.1.dht.temperature 21.0 +myc set value field gw1.1.1.V_CUSTOM 23.5 +myc set value field mysensor.1.dht.temperature 21.0 ``` Do not use `myc set field` for this. `set field` always updates a stored key path (`formatter.onReceive`, `name`, `unit`, …). @@ -621,15 +666,15 @@ Do not use `myc set field` for this. `set field` always updates a stored key pat ## 8. Delete, enable, disable, reload, reboot, action -`delete`, `enable`, `disable`, and `reload` take **storage ids** (the `id` column from `get`). Node `reboot` and `action node` take **quick ids** (`gatewayId.nodeId`). +`delete`, `enable`, `disable`, and `reload` take the **alias** first, then **storage ids** (the `id` column from `get`). Node `reboot` and `action node` take the alias, then **quick ids** (`gatewayId.nodeId`). ### Delete ```bash -myc delete gateway [...] -myc delete node -myc delete source -myc delete field +myc delete gateway [...] +myc delete node +myc delete source +myc delete field ``` | Resource | Aliases | @@ -651,8 +696,8 @@ myc delete field ### Enable / disable ```bash -myc enable gateway -myc disable task +myc enable gateway +myc disable task ``` Supported: `gateway`, `virtual-device`, `virtual-assistant`, `task`, `schedule`, `handler` (same aliases as `get`). @@ -660,8 +705,8 @@ Supported: `gateway`, `virtual-device`, `virtual-assistant`, `task`, `schedule`, ### Reload ```bash -myc reload gateway mysensor gw2 -myc reload virtual-assistant [...] +myc reload gateway mysensor gw2 +myc reload virtual-assistant [...] ``` Supported: `gateway`, `virtual-assistant`. @@ -669,18 +714,18 @@ Supported: `gateway`, `virtual-assistant`. ### Reboot ```bash -myc reboot node mysensor.1 mysensor.2 +myc reboot node mysensor.1 mysensor.2 ``` -Sends a reboot action to each node. Same as `myc action node reboot …`. +Sends a reboot action to each node. Same as `myc action node reboot …`. ### Action Node ids are quick ids: `gatewayId.nodeId`. Gateway ids are the gateway id. Separate multiple ids with spaces. ```bash -myc action node [...] -myc action gateway discover-nodes [...] +myc action node [...] +myc action gateway discover-nodes [...] ``` | Target | Actions | @@ -689,15 +734,15 @@ myc action gateway discover-nodes [...] | `gateway` | `discover-nodes` | ```bash -myc action node reboot mysensor.1 mysensor.2 -myc action node reset mysensor.1 -myc action node firmware-update mysensor.1 -myc action node heartbeat mysensor.1 -myc action node refresh-node-info mysensor.1 -myc action gateway discover-nodes mysensor gw2 +myc action node reboot mysensor.1 mysensor.2 +myc action node reset mysensor.1 +myc action node firmware-update mysensor.1 +myc action node heartbeat mysensor.1 +myc action node refresh-node-info mysensor.1 +myc action gateway discover-nodes mysensor gw2 ``` -To reload gateways, use `myc reload gateway [...]`. There is no gateway restart or reboot action. +To reload gateways, use `myc reload gateway [...]`. There is no gateway restart or reboot action. --- diff --git a/pkg/types/client/types.go b/pkg/types/client/types.go index ac884e1..3838c90 100644 --- a/pkg/types/client/types.go +++ b/pkg/types/client/types.go @@ -11,33 +11,50 @@ const ( EncodePrefix = "BASE64/" ) -// Config used across to access the mycontroller +// Config holds named server aliases. type Config struct { + Aliases map[string]Alias `json:"aliases" yaml:"aliases" mapstructure:"aliases"` +} + +// Alias is one named connection (server + user session). +type Alias struct { URL string `json:"url" yaml:"url" mapstructure:"url"` Insecure bool `json:"insecure" yaml:"insecure" mapstructure:"insecure"` Username string `json:"username" yaml:"username" mapstructure:"username"` - Password string `json:"password" yaml:"password" mapstructure:"password"` // encode as base64 + Password string `json:"password" yaml:"password" mapstructure:"password"` LoginTime string `json:"loginTime" yaml:"loginTime" mapstructure:"loginTime"` ExpiresIn string `json:"expiresIn" yaml:"expiresIn" mapstructure:"expiresIn"` } -// GetPassword decodes and returns the password -func (c *Config) GetPassword() string { - if strings.HasPrefix(c.Password, EncodePrefix) { - password := strings.Replace(c.Password, EncodePrefix, "", 1) +func (c *Config) EnsureAliases() { + if c.Aliases == nil { + c.Aliases = map[string]Alias{} + } +} + +func (a *Alias) GetPassword() string { + if strings.HasPrefix(a.Password, EncodePrefix) { + password := strings.Replace(a.Password, EncodePrefix, "", 1) decodedPassword, err := base64.StdEncoding.DecodeString(password) if err != nil { log.Fatal("error on decoding the password", err) } return string(decodedPassword) } - return c.Password + return a.Password +} + +func (a *Alias) EncodePassword() { + if a.Password != "" && !strings.HasPrefix(a.Password, EncodePrefix) { + encodedPassword := base64.StdEncoding.EncodeToString([]byte(a.Password)) + a.Password = fmt.Sprintf("%s%s", EncodePrefix, encodedPassword) + } } -// EncodePassword encodes and update the password -func (c *Config) EncodePassword() { - if c.Password != "" && !strings.HasPrefix(c.Password, EncodePrefix) { - encodedPassword := base64.StdEncoding.EncodeToString([]byte(c.Password)) - c.Password = fmt.Sprintf("%s%s", EncodePrefix, encodedPassword) +func (c *Config) EncodePasswords() { + c.EnsureAliases() + for name, alias := range c.Aliases { + alias.EncodePassword() + c.Aliases[name] = alias } }