From 28527147e3d9ef263fa85eff3504fbe79c37fd43 Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Fri, 11 Sep 2026 16:16:55 +0530 Subject: [PATCH 1/2] enhance CLI with apply, merge, set, and firmware upload Add myc apply for YAML/JSON resource files (add, merge, delete, replace), myc set for nested properties with --file, myc set value field for live values, and myc upload firmware for binaries. Document the CLI in docs/cli.md. --- cmd/client/api/api.go | 1 + cmd/client/api/resource.go | 280 ++++++++++ cmd/client/api/set_path.go | 174 +++++++ cmd/client/api/set_path_test.go | 61 +++ cmd/client/command/apply/apply.go | 518 ++++++++++++++++++ cmd/client/command/apply/apply_test.go | 684 ++++++++++++++++++++++++ cmd/client/command/apply/client.go | 161 ++++++ cmd/client/command/apply/cmd.go | 140 +++++ cmd/client/command/apply/merge.go | 128 +++++ cmd/client/command/apply/merge_test.go | 88 ++++ cmd/client/command/apply/parse.go | 559 ++++++++++++++++++++ cmd/client/command/apply/parse_test.go | 353 +++++++++++++ cmd/client/command/set/cmd.go | 112 +++- cmd/client/command/set/parse_test.go | 68 +++ cmd/client/command/set/set_cmd.go | 76 ++- cmd/client/command/set/value_cmd.go | 35 ++ cmd/client/command/upload/cmd.go | 126 +++++ cmd/client/command/upload/cmd_test.go | 43 ++ cmd/client/main.go | 2 + docs/cli.md | 692 +++++++++++++++++++++++++ pkg/api/field/api.go | 10 +- pkg/api/field/api_test.go | 68 +++ pkg/utils/http_client_json/client.go | 63 +++ 23 files changed, 4407 insertions(+), 35 deletions(-) create mode 100644 cmd/client/api/resource.go create mode 100644 cmd/client/api/set_path.go create mode 100644 cmd/client/api/set_path_test.go create mode 100644 cmd/client/command/apply/apply.go create mode 100644 cmd/client/command/apply/apply_test.go create mode 100644 cmd/client/command/apply/client.go create mode 100644 cmd/client/command/apply/cmd.go create mode 100644 cmd/client/command/apply/merge.go create mode 100644 cmd/client/command/apply/merge_test.go create mode 100644 cmd/client/command/apply/parse.go create mode 100644 cmd/client/command/apply/parse_test.go create mode 100644 cmd/client/command/set/parse_test.go create mode 100644 cmd/client/command/set/value_cmd.go create mode 100644 cmd/client/command/upload/cmd.go create mode 100644 cmd/client/command/upload/cmd_test.go create mode 100644 docs/cli.md create mode 100644 pkg/api/field/api_test.go diff --git a/cmd/client/api/api.go b/cmd/client/api/api.go index 49674a5f..ae765f13 100644 --- a/cmd/client/api/api.go +++ b/cmd/client/api/api.go @@ -25,6 +25,7 @@ const ( API_FIRMWARE_LIST = "/api/firmware" API_FIRMWARE_DELETE = "/api/firmware" + API_FIRMWARE_UPLOAD = "/api/firmware/upload" API_DATA_REPOSITORY_LIST = "/api/datarepository" API_DATA_REPOSITORY_DELETE = "/api/datarepository" diff --git a/cmd/client/api/resource.go b/cmd/client/api/resource.go new file mode 100644 index 00000000..acc1130b --- /dev/null +++ b/cmd/client/api/resource.go @@ -0,0 +1,280 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/mycontroller-org/server/v2/pkg/json" + "github.com/mycontroller-org/server/v2/pkg/types" + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + "github.com/mycontroller-org/server/v2/pkg/utils" + httpUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_client_json" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" +) + +func (c *Client) SaveGateway(gateway *gwTY.Config) error { + return c.saveResource(API_GATEWAY_LIST, gateway) +} + +func (c *Client) SaveFirmware(firmware *firmwareTY.Firmware) error { + return c.saveResource(API_FIRMWARE_LIST, firmware) +} + +func (c *Client) SaveDataRepository(item *dataRepoTY.Config) error { + return c.saveResource(API_DATA_REPOSITORY_LIST, item) +} + +func (c *Client) UploadFirmware(id, filename string) error { + client := httpUtils.New(c.Insecure, "10m") + url := fmt.Sprintf("%s%s/%s", c.ServerAddress, API_FIRMWARE_UPLOAD, id) + _, err := client.ExecuteMultipart(url, http.MethodPost, c.getHeaders(nil), "file", filename, http.StatusOK) + return err +} + +func (c *Client) SaveNode(node *nodeTY.Node) error { + return c.saveResource(API_NODE_LIST, node) +} + +func (c *Client) SaveSource(source *sourceTY.Source) error { + return c.saveResource(API_SOURCE_LIST, source) +} + +func (c *Client) SaveField(field *fieldTY.Field) error { + return c.saveResource(API_FIELD_LIST, field) +} + +func (c *Client) GetNode(id string) (*nodeTY.Node, error) { + item := &nodeTY.Node{} + found, err := c.getByID(API_NODE_LIST, id, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) GetSource(id string) (*sourceTY.Source, error) { + item := &sourceTY.Source{} + found, err := c.getByID(API_SOURCE_LIST, id, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) GetField(id string) (*fieldTY.Field, error) { + item := &fieldTY.Field{} + found, err := c.getByID(API_FIELD_LIST, id, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindGateway(id string) (*gwTY.Config, error) { + if id == "" { + return nil, nil + } + item := &gwTY.Config{} + found, err := c.findResource(API_GATEWAY_LIST, idFilters(id), item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindFirmware(id string) (*firmwareTY.Firmware, error) { + if id == "" { + return nil, nil + } + item := &firmwareTY.Firmware{} + found, err := c.findResource(API_FIRMWARE_LIST, idFilters(id), item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindDataRepository(id string) (*dataRepoTY.Config, error) { + if id == "" { + return nil, nil + } + item := &dataRepoTY.Config{} + found, err := c.findResource(API_DATA_REPOSITORY_LIST, idFilters(id), item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindNode(id, gatewayID, nodeID string) (*nodeTY.Node, error) { + item := &nodeTY.Node{} + found, err := c.findResource(API_NODE_LIST, idFilters(id), item) + if err != nil { + return nil, err + } + if found { + return item, nil + } + if gatewayID == "" || nodeID == "" { + return nil, nil + } + item = &nodeTY.Node{} + found, err = c.findResource(API_NODE_LIST, []storageTY.Filter{ + equalFilter(types.KeyGatewayID, gatewayID), + equalFilter(types.KeyNodeID, nodeID), + }, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindSource(id, gatewayID, nodeID, sourceID string) (*sourceTY.Source, error) { + item := &sourceTY.Source{} + found, err := c.findResource(API_SOURCE_LIST, idFilters(id), item) + if err != nil { + return nil, err + } + if found { + return item, nil + } + if gatewayID == "" || nodeID == "" || sourceID == "" { + return nil, nil + } + item = &sourceTY.Source{} + found, err = c.findResource(API_SOURCE_LIST, []storageTY.Filter{ + equalFilter(types.KeyGatewayID, gatewayID), + equalFilter(types.KeyNodeID, nodeID), + equalFilter(types.KeySourceID, sourceID), + }, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindField(id, gatewayID, nodeID, sourceID, fieldID string) (*fieldTY.Field, error) { + item := &fieldTY.Field{} + found, err := c.findResource(API_FIELD_LIST, idFilters(id), item) + if err != nil { + return nil, err + } + if found { + return item, nil + } + if gatewayID == "" || nodeID == "" || sourceID == "" || fieldID == "" { + return nil, nil + } + item = &fieldTY.Field{} + found, err = c.findResource(API_FIELD_LIST, []storageTY.Filter{ + equalFilter(types.KeyGatewayID, gatewayID), + equalFilter(types.KeyNodeID, nodeID), + equalFilter(types.KeySourceID, sourceID), + equalFilter(types.KeyFieldID, fieldID), + }, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) saveResource(api string, body interface{}) error { + _, err := c.executeJson(api, http.MethodPost, nil, nil, body, http.StatusOK) + return err +} + +func (c *Client) getByID(api, id string, dest interface{}) (bool, error) { + if id == "" { + return false, nil + } + res, err := c.executeJson(fmt.Sprintf("%s/%s", api, id), http.MethodGet, nil, nil, nil, 0) + if err != nil { + return false, err + } + if res.StatusCode == http.StatusNotFound { + return false, nil + } + if res.StatusCode != http.StatusOK { + // FindOne currently returns 500 when no document matches + if res.StatusCode == http.StatusInternalServerError && strings.Contains(res.StringBody(), "no documents") { + return false, nil + } + return false, fmt.Errorf("failed with status code. [statusCode: %v, body: %s]", res.StatusCode, res.StringBody()) + } + if len(res.Body) == 0 { + return false, nil + } + if err := json.Unmarshal(res.Body, dest); err != nil { + return false, err + } + return true, nil +} + +func (c *Client) findResource(api string, filters []storageTY.Filter, dest interface{}) (bool, error) { + if len(filters) == 0 { + return false, nil + } + queryParams, err := listQueryParams(filters, 1) + if err != nil { + return false, err + } + result, err := c.listResource(api, queryParams) + if err != nil { + return false, err + } + return decodeFirst(result, dest) +} + +func listQueryParams(filters []storageTY.Filter, limit uint64) (map[string]interface{}, error) { + filtersBytes, err := json.Marshal(filters) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "limit": limit, + "offset": uint64(0), + "filter": string(filtersBytes), + }, nil +} + +func decodeFirst(result *storageTY.Result, dest interface{}) (bool, error) { + if result == nil || result.Data == nil { + return false, nil + } + items, ok := result.Data.([]interface{}) + if !ok { + return false, fmt.Errorf("invalid response type:%T", result.Data) + } + if len(items) == 0 { + return false, nil + } + data, ok := items[0].(map[string]interface{}) + if !ok { + return false, fmt.Errorf("invalid item type:%T", items[0]) + } + if err := utils.MapToStruct(utils.TagNameJSON, data, dest); err != nil { + return false, err + } + return true, nil +} + +func idFilters(id string) []storageTY.Filter { + if id == "" { + return nil + } + return []storageTY.Filter{equalFilter(types.KeyID, id)} +} + +func equalFilter(key, value string) storageTY.Filter { + return storageTY.Filter{ + Key: key, + Value: value, + Operator: storageTY.OperatorEqual, + } +} diff --git a/cmd/client/api/set_path.go b/cmd/client/api/set_path.go new file mode 100644 index 00000000..d3cbc534 --- /dev/null +++ b/cmd/client/api/set_path.go @@ -0,0 +1,174 @@ +package api + +import ( + "fmt" + "strings" + + "github.com/mycontroller-org/server/v2/pkg/json" + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + "github.com/tidwall/sjson" +) + +// SetResourcePath loads a resource, sets a dotted JSON path, and saves it. +func (c *Client) SetResourcePath(kind, selector, keyPath, value string, rawText bool) error { + if strings.TrimSpace(keyPath) == "" { + return fmt.Errorf("key path is required") + } + if strings.TrimSpace(selector) == "" { + return fmt.Errorf("resource id is required") + } + + switch kind { + case "gateway": + item, err := c.FindGateway(selector) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("gateway %s is not present", selector) + } + updated := &gwTY.Config{} + if err := applyJSONPath(item, keyPath, value, rawText, updated); err != nil { + return err + } + return c.SaveGateway(updated) + case "node": + item, err := c.findNodeSelector(selector) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("node %s is not present", selector) + } + updated := &nodeTY.Node{} + if err := applyJSONPath(item, keyPath, value, rawText, updated); err != nil { + return err + } + return c.SaveNode(updated) + case "source": + item, err := c.findSourceSelector(selector) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("source %s is not present", selector) + } + updated := &sourceTY.Source{} + if err := applyJSONPath(item, keyPath, value, rawText, updated); err != nil { + return err + } + return c.SaveSource(updated) + case "field": + item, err := c.findFieldSelector(selector) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("field %s is not present", selector) + } + updated := &fieldTY.Field{} + if err := applyJSONPath(item, keyPath, value, rawText, updated); err != nil { + return err + } + return c.SaveField(updated) + case "firmware": + item, err := c.FindFirmware(selector) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("firmware %s is not present", selector) + } + updated := &firmwareTY.Firmware{} + if err := applyJSONPath(item, keyPath, value, rawText, updated); err != nil { + return err + } + return c.SaveFirmware(updated) + case "data-repository": + item, err := c.FindDataRepository(selector) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("data-repository %s is not present", selector) + } + updated := &dataRepoTY.Config{} + if err := applyJSONPath(item, keyPath, value, rawText, updated); err != nil { + return err + } + return c.SaveDataRepository(updated) + default: + return fmt.Errorf("unsupported kind %q", kind) + } +} + +func (c *Client) findNodeSelector(selector string) (*nodeTY.Node, error) { + item, err := c.FindNode(selector, "", "") + if err != nil || item != nil { + return item, err + } + parts := strings.Split(selector, ".") + if len(parts) == 2 { + return c.FindNode("", parts[0], parts[1]) + } + return nil, nil +} + +func (c *Client) findSourceSelector(selector string) (*sourceTY.Source, error) { + item, err := c.FindSource(selector, "", "", "") + if err != nil || item != nil { + return item, err + } + parts := strings.Split(selector, ".") + if len(parts) == 3 { + return c.FindSource("", parts[0], parts[1], parts[2]) + } + return nil, nil +} + +func (c *Client) findFieldSelector(selector string) (*fieldTY.Field, error) { + item, err := c.FindField(selector, "", "", "", "") + if err != nil || item != nil { + return item, err + } + parts := strings.Split(selector, ".") + if len(parts) == 4 { + return c.FindField("", parts[0], parts[1], parts[2], parts[3]) + } + return nil, nil +} + +func applyJSONPath(in interface{}, keyPath, value string, rawText bool, out interface{}) error { + raw, err := json.Marshal(in) + if err != nil { + return err + } + updated, err := sjson.Set(string(raw), keyPath, decodeSetValue(value, rawText)) + if err != nil { + return fmt.Errorf("invalid key path %q: %w", keyPath, err) + } + if err := json.Unmarshal([]byte(updated), out); err != nil { + return fmt.Errorf("failed to apply key path %q: %w", keyPath, err) + } + return nil +} + +func decodeSetValue(value string, rawText bool) interface{} { + if rawText { + return value + } + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return value + } + var decoded interface{} + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return value + } + return decoded +} diff --git a/cmd/client/api/set_path_test.go b/cmd/client/api/set_path_test.go new file mode 100644 index 00000000..5a66d8b3 --- /dev/null +++ b/cmd/client/api/set_path_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "testing" + + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyJSONPathNestedScript(t *testing.T) { + in := &fieldTY.Field{ + ID: "id-1", + GatewayID: "gw", + NodeID: "n", + SourceID: "s", + FieldID: "temp", + Name: "Temperature", + } + out := &fieldTY.Field{} + err := applyJSONPath(in, "formatter.onReceive", "return value;", true, out) + require.NoError(t, err) + assert.Equal(t, "id-1", out.ID) + assert.Equal(t, "Temperature", out.Name) + assert.Equal(t, "return value;", out.Formatter.OnReceive) +} + +func TestApplyJSONPathDataRepository(t *testing.T) { + in := &dataRepoTY.Config{ + ID: "ota", + Data: map[string]interface{}{"disabled": false}, + } + out := &dataRepoTY.Config{} + err := applyJSONPath(in, "data.onConfig", "var x = 1;", true, out) + require.NoError(t, err) + assert.Equal(t, "ota", out.ID) + assert.Equal(t, "var x = 1;", out.Data["onConfig"]) + assert.Equal(t, false, out.Data["disabled"]) +} + +func TestApplyJSONPathDecodesJSONValues(t *testing.T) { + type sample struct { + Enabled bool `json:"enabled"` + Count float64 `json:"count"` + Name string `json:"name"` + } + in := &sample{Name: "keep"} + out := &sample{} + require.NoError(t, applyJSONPath(in, "enabled", "true", false, out)) + assert.True(t, out.Enabled) + assert.Equal(t, "keep", out.Name) + + out = &sample{} + require.NoError(t, applyJSONPath(in, "count", "3", false, out)) + assert.Equal(t, float64(3), out.Count) + + out = &sample{} + require.NoError(t, applyJSONPath(in, "name", "true", true, out)) + assert.Equal(t, "true", out.Name) +} diff --git a/cmd/client/command/apply/apply.go b/cmd/client/command/apply/apply.go new file mode 100644 index 00000000..7256865c --- /dev/null +++ b/cmd/client/command/apply/apply.go @@ -0,0 +1,518 @@ +package apply + +import ( + "errors" + "fmt" + "io" + + "github.com/mycontroller-org/server/v2/pkg/json" + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + "github.com/mycontroller-org/server/v2/pkg/utils" + gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + "github.com/olekukonko/tablewriter" +) + +// ErrApplyFailed is returned after the result table is printed when any row failed. +var ErrApplyFailed = errors.New("apply failed") + +const ( + actionAdd = "add" + actionMerge = "merge" + actionDelete = "delete" + actionReplace = "replace" + actionNotAvailable = "not available" +) + +// ResourceClient looks up and mutates nodes, sources, and fields. +type ResourceClient interface { + FindGateway(id string) (idFound string, err error) + FindNode(id, gatewayID, nodeID string) (idFound string, err error) + FindSource(id, gatewayID, nodeID, sourceID string) (idFound string, err error) + FindField(id, gatewayID, nodeID, sourceID, fieldID string) (idFound string, err error) + FindFirmware(id string) (idFound string, err error) + FindDataRepository(id string) (idFound string, err error) + SaveGateway(resource Resource) error + SaveNode(resource Resource) error + SaveSource(resource Resource) error + SaveField(resource Resource) error + SaveFirmware(resource Resource) error + SaveDataRepository(resource Resource) error + DeleteGateway(ids ...string) error + DeleteNode(ids ...string) error + DeleteSource(ids ...string) error + DeleteField(ids ...string) error + DeleteFirmware(ids ...string) error + DeleteDataRepository(ids ...string) error + GetExisting(resource Resource) ([]byte, error) +} + +type plannedAction struct { + Resource Resource + Action string + ExistingID string + Err error +} + +type applyRow struct { + Resource string + Action string + Status string +} + +// Apply plans and optionally executes resource operations. +// When dryRun is true, the server is still queried so add-already-exists can be verified. +func Apply(client ResourceClient, resources []Resource, replace, dryRun bool, out, errOut io.Writer) error { + _ = errOut + if len(resources) == 0 { + return fmt.Errorf("no resources to apply") + } + + plans := make([]plannedAction, 0, len(resources)) + planFailed := false + pending := newPendingParents() + for _, resource := range resources { + plan := planResource(client, resource, replace, pending) + plans = append(plans, plan) + if plan.Err != nil { + planFailed = true + continue + } + switch plan.Action { + case actionAdd, actionMerge, actionReplace: + pending.remember(plan.Resource, true) + case actionDelete: + pending.remember(plan.Resource, false) + } + } + + rows := make([]applyRow, 0, len(plans)) + var applyErr error + executed := newPendingParents() + for _, plan := range plans { + var execErr error + if plan.Err == nil && plan.Action != actionNotAvailable && !dryRun { + if err := checkExecutedParent(plan.Resource, executed); err != nil { + execErr = err + } else if err := executePlan(client, plan); err != nil { + execErr = err + } + switch plan.Action { + case actionAdd, actionMerge, actionReplace: + executed.remember(plan.Resource, execErr == nil) + case actionDelete: + executed.remember(plan.Resource, false) + } + } + if execErr != nil { + applyErr = execErr + } + rows = append(rows, applyRow{ + Resource: plan.Resource.TableResource(), + Action: tableAction(plan), + Status: tableStatus(plan, dryRun, execErr), + }) + } + + printApplyTable(out, rows) + + if planFailed || applyErr != nil { + return ErrApplyFailed + } + return nil +} + +func tableAction(plan plannedAction) string { + if plan.Resource.Operation != "" { + return plan.Resource.Operation + } + if plan.Action == actionNotAvailable { + return actionDelete + } + if plan.Action == actionReplace { + return actionAdd + } + if plan.Action != "" { + return plan.Action + } + return "-" +} + +func tableStatus(plan plannedAction, dryRun bool, execErr error) string { + if plan.Err != nil { + return "failed: " + plan.Err.Error() + } + if plan.Action == actionNotAvailable { + return actionNotAvailable + } + if dryRun { + return "dry-run" + } + if execErr != nil { + return "failed: " + execErr.Error() + } + if plan.Action == actionReplace { + return "replaced" + } + return "ok" +} + +func printApplyTable(out io.Writer, rows []applyRow) { + table := tablewriter.NewWriter(out) + table.SetHeader([]string{"RESOURCE", "ACTION", "STATUS"}) + table.SetAutoFormatHeaders(false) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetAutoWrapText(false) + table.SetBorder(false) + table.SetHeaderLine(false) + table.SetCenterSeparator("") + table.SetColumnSeparator(" ") + table.SetRowSeparator("") + table.SetTablePadding(" ") + table.SetNoWhiteSpace(true) + for _, row := range rows { + table.Append([]string{row.Resource, row.Action, row.Status}) + } + table.Render() +} + +func planResource(client ResourceClient, resource Resource, replace bool, pending *pendingParents) plannedAction { + existingID, err := findExisting(client, resource) + if err != nil { + return plannedAction{Resource: resource, Err: fmt.Errorf("lookup failed: %w", err)} + } + + plan := plannedAction{Resource: resource, ExistingID: existingID, Action: resource.Operation} + switch resource.Operation { + case OperationAdd: + if existingID != "" { + if !replace && !resource.Replace { + plan.Err = fmt.Errorf("already exists") + return plan + } + // delete then add with the same id so existing references stay valid + resource.SetID(existingID) + plan.Resource = resource + plan.Action = actionReplace + } else { + assignSaveID(&resource) + plan.Resource = resource + plan.Action = actionAdd + } + case OperationMerge: + if existingID == "" { + plan.Err = fmt.Errorf("not found") + return plan + } + resource.SetID(existingID) + existingJSON, err := client.GetExisting(resource) + if err != nil { + plan.Err = fmt.Errorf("lookup failed: %w", err) + return plan + } + if err := mergeExisting(&resource, existingJSON); err != nil { + plan.Err = fmt.Errorf("merge failed: %w", err) + return plan + } + plan.Resource = resource + plan.Action = actionMerge + case OperationDelete: + if existingID == "" { + plan.Action = actionNotAvailable + return plan + } + resource.SetID(existingID) + plan.Resource = resource + plan.Action = actionDelete + default: + plan.Err = fmt.Errorf("unsupported operation %q", resource.Operation) + return plan + } + + if plan.Err == nil && plan.Action != actionDelete && plan.Action != actionNotAvailable { + if err := checkParent(client, plan.Resource, pending); err != nil { + plan.Err = err + } + } + return plan +} + +type pendingParents struct { + gateways map[string]bool + nodes map[string]bool + sources map[string]bool +} + +func newPendingParents() *pendingParents { + return &pendingParents{ + gateways: map[string]bool{}, + nodes: map[string]bool{}, + sources: map[string]bool{}, + } +} + +func nodeParentKey(gatewayID, nodeID string) string { + return gatewayID + "." + nodeID +} + +func sourceParentKey(gatewayID, nodeID, sourceID string) string { + return gatewayID + "." + nodeID + "." + sourceID +} + +func (p *pendingParents) remember(resource Resource, present bool) { + if p == nil { + return + } + gatewayID, nodeID, sourceID, _ := resource.NaturalKeys() + switch resource.Kind { + case KindGateway: + if id := resource.ID(); id != "" { + p.gateways[id] = present + } + case KindNode: + p.nodes[nodeParentKey(gatewayID, nodeID)] = present + case KindSource: + p.sources[sourceParentKey(gatewayID, nodeID, sourceID)] = present + } +} + +func (p *pendingParents) knownGateway(gatewayID string) (present bool, known bool) { + if p == nil { + return false, false + } + present, known = p.gateways[gatewayID] + return present, known +} + +func (p *pendingParents) knownNode(gatewayID, nodeID string) (present bool, known bool) { + if p == nil { + return false, false + } + present, known = p.nodes[nodeParentKey(gatewayID, nodeID)] + return present, known +} + +func (p *pendingParents) knownSource(gatewayID, nodeID, sourceID string) (present bool, known bool) { + if p == nil { + return false, false + } + present, known = p.sources[sourceParentKey(gatewayID, nodeID, sourceID)] + return present, known +} + +func checkParent(client ResourceClient, resource Resource, pending *pendingParents) error { + gatewayID, nodeID, sourceID, _ := resource.NaturalKeys() + switch resource.Kind { + case KindNode: + if present, known := pending.knownGateway(gatewayID); known { + if !present { + return fmt.Errorf("parent gateway %s is not present", gatewayID) + } + return nil + } + id, err := client.FindGateway(gatewayID) + if err != nil { + return fmt.Errorf("parent lookup failed: %w", err) + } + if id == "" { + return fmt.Errorf("parent gateway %s is not present", gatewayID) + } + case KindSource: + if present, known := pending.knownNode(gatewayID, nodeID); known { + if !present { + return fmt.Errorf("parent node %s.%s is not present", gatewayID, nodeID) + } + return nil + } + id, err := client.FindNode("", gatewayID, nodeID) + if err != nil { + return fmt.Errorf("parent lookup failed: %w", err) + } + if id == "" { + return fmt.Errorf("parent node %s.%s is not present", gatewayID, nodeID) + } + case KindField: + if present, known := pending.knownSource(gatewayID, nodeID, sourceID); known { + if !present { + return fmt.Errorf("parent source %s.%s.%s is not present", gatewayID, nodeID, sourceID) + } + return nil + } + id, err := client.FindSource("", gatewayID, nodeID, sourceID) + if err != nil { + return fmt.Errorf("parent lookup failed: %w", err) + } + if id == "" { + return fmt.Errorf("parent source %s.%s.%s is not present", gatewayID, nodeID, sourceID) + } + } + return nil +} + +func checkExecutedParent(resource Resource, executed *pendingParents) error { + gatewayID, nodeID, sourceID, _ := resource.NaturalKeys() + switch resource.Kind { + case KindNode: + if present, known := executed.knownGateway(gatewayID); known && !present { + return fmt.Errorf("parent gateway %s is not present", gatewayID) + } + case KindSource: + if present, known := executed.knownNode(gatewayID, nodeID); known && !present { + return fmt.Errorf("parent node %s.%s is not present", gatewayID, nodeID) + } + case KindField: + if present, known := executed.knownSource(gatewayID, nodeID, sourceID); known && !present { + return fmt.Errorf("parent source %s.%s.%s is not present", gatewayID, nodeID, sourceID) + } + } + return nil +} + +func mergeExisting(resource *Resource, existingJSON []byte) error { + if len(existingJSON) == 0 { + return fmt.Errorf("existing resource is empty") + } + var base map[string]interface{} + if err := json.Unmarshal(existingJSON, &base); err != nil { + return err + } + if base == nil { + base = map[string]interface{}{} + } + mergedMap := deepMergeMaps(base, resource.Payload) + mergedMap["id"] = resource.ID() + merged, err := json.Marshal(mergedMap) + if err != nil { + return err + } + return decodeMerged(resource, merged) +} + +func decodeMerged(resource *Resource, merged []byte) error { + switch resource.Kind { + case KindGateway: + resource.Gateway = &gwTY.Config{} + return json.Unmarshal(merged, resource.Gateway) + case KindNode: + resource.Node = &nodeTY.Node{} + return json.Unmarshal(merged, resource.Node) + case KindSource: + resource.Src = &sourceTY.Source{} + return json.Unmarshal(merged, resource.Src) + case KindField: + resource.Field = &fieldTY.Field{} + return json.Unmarshal(merged, resource.Field) + case KindFirmware: + resource.Firmware = &firmwareTY.Firmware{} + return json.Unmarshal(merged, resource.Firmware) + case KindDataRepository: + resource.DataRepository = &dataRepoTY.Config{} + return json.Unmarshal(merged, resource.DataRepository) + default: + return fmt.Errorf("unsupported kind %q", resource.Kind) + } +} + +// assignSaveID sets an id when the HTTP API requires one. +// Field create leaves id empty so the server treats it as a new resource. +func assignSaveID(resource *Resource) { + if resource.ID() != "" { + return + } + if resource.Kind == KindField || resource.Kind == KindGateway || resource.Kind == KindFirmware || resource.Kind == KindDataRepository { + return + } + resource.SetID(utils.RandUUID()) +} + +func executePlan(client ResourceClient, plan plannedAction) error { + switch plan.Action { + case actionAdd, actionMerge: + return saveResource(client, plan.Resource) + case actionReplace: + if err := deleteResource(client, plan.Resource, plan.ExistingID); err != nil { + return fmt.Errorf("replace delete failed: %w", err) + } + if err := saveResource(client, plan.Resource); err != nil { + if plan.Resource.Kind == KindFirmware { + return fmt.Errorf("deleted existing resource, but recreate failed: %w (upload the firmware binary again)", err) + } + return fmt.Errorf("deleted existing resource, but recreate failed: %w", err) + } + return nil + case actionDelete: + return deleteResource(client, plan.Resource, plan.ExistingID) + default: + return fmt.Errorf("unknown action %q", plan.Action) + } +} + +func findExisting(client ResourceClient, resource Resource) (string, error) { + id := resource.ID() + gatewayID, nodeID, sourceID, fieldID := resource.NaturalKeys() + switch resource.Kind { + case KindGateway: + return client.FindGateway(id) + case KindFirmware: + return client.FindFirmware(id) + case KindDataRepository: + return client.FindDataRepository(id) + case KindNode: + return client.FindNode(id, gatewayID, nodeID) + case KindSource: + return client.FindSource(id, gatewayID, nodeID, sourceID) + case KindField: + return client.FindField(id, gatewayID, nodeID, sourceID, fieldID) + default: + return "", fmt.Errorf("unsupported kind %q", resource.Kind) + } +} + +func saveResource(client ResourceClient, resource Resource) error { + switch resource.Kind { + case KindGateway: + return client.SaveGateway(resource) + case KindFirmware: + return client.SaveFirmware(resource) + case KindDataRepository: + return client.SaveDataRepository(resource) + case KindNode: + return client.SaveNode(resource) + case KindSource: + return client.SaveSource(resource) + case KindField: + return client.SaveField(resource) + default: + return fmt.Errorf("unsupported kind %q", resource.Kind) + } +} + +func deleteResource(client ResourceClient, resource Resource, existingID string) error { + id := existingID + if id == "" { + id = resource.ID() + } + if id == "" { + return fmt.Errorf("missing id for delete") + } + switch resource.Kind { + case KindGateway: + return client.DeleteGateway(id) + case KindFirmware: + return client.DeleteFirmware(id) + case KindDataRepository: + return client.DeleteDataRepository(id) + case KindNode: + return client.DeleteNode(id) + case KindSource: + return client.DeleteSource(id) + case KindField: + return client.DeleteField(id) + default: + return fmt.Errorf("unsupported kind %q", resource.Kind) + } +} diff --git a/cmd/client/command/apply/apply_test.go b/cmd/client/command/apply/apply_test.go new file mode 100644 index 00000000..7e0bb46d --- /dev/null +++ b/cmd/client/command/apply/apply_test.go @@ -0,0 +1,684 @@ +package apply + +import ( + "bytes" + "fmt" + "strings" + "testing" + + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeClient struct { + existing map[string]string + existingJSON map[string][]byte + saved []Resource + deleted []string + saveErr error + findErr error +} + +func (f *fakeClient) find(kind, id, gatewayID, nodeID, sourceID, fieldID string) (string, error) { + if f.findErr != nil { + return "", f.findErr + } + if f.existing == nil { + return "", nil + } + if id != "" { + if found, ok := f.existing[kind+"/id/"+id]; ok { + return found, nil + } + } + if gatewayID != "" { + if found, ok := f.existing[strings.Join([]string{kind, gatewayID, nodeID, sourceID, fieldID}, "/")]; ok { + return found, nil + } + } + return "", nil +} + +func (f *fakeClient) FindGateway(id string) (string, error) { + return f.find("gateway", id, "", "", "", "") +} +func (f *fakeClient) FindNode(id, gatewayID, nodeID string) (string, error) { + return f.find(KindNode, id, gatewayID, nodeID, "", "") +} +func (f *fakeClient) FindSource(id, gatewayID, nodeID, sourceID string) (string, error) { + return f.find(KindSource, id, gatewayID, nodeID, sourceID, "") +} +func (f *fakeClient) FindField(id, gatewayID, nodeID, sourceID, fieldID string) (string, error) { + return f.find(KindField, id, gatewayID, nodeID, sourceID, fieldID) +} +func (f *fakeClient) FindFirmware(id string) (string, error) { + return f.find(KindFirmware, id, "", "", "", "") +} +func (f *fakeClient) FindDataRepository(id string) (string, error) { + return f.find(KindDataRepository, id, "", "", "", "") +} +func (f *fakeClient) SaveGateway(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SaveNode(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SaveSource(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SaveField(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SaveFirmware(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SaveDataRepository(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) DeleteGateway(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeleteNode(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeleteSource(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeleteField(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeleteFirmware(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeleteDataRepository(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) GetExisting(resource Resource) ([]byte, error) { + id := resource.ID() + if f.existingJSON != nil { + if data, ok := f.existingJSON[id]; ok { + return data, nil + } + } + if id == "" { + return []byte("{}"), nil + } + return []byte(`{"id":"` + id + `"}`), nil +} + +func assertApplyRow(t *testing.T, out, resource, action, status string) { + t.Helper() + assert.Contains(t, out, "RESOURCE") + assert.Contains(t, out, "ACTION") + assert.Contains(t, out, "STATUS") + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, resource) && strings.Contains(line, action) && strings.Contains(line, status) { + return + } + } + t.Fatalf("missing row resource=%q action=%q status=%q\noutput:\n%s", resource, action, status, out) +} + +func withGateway(existing map[string]string, gatewayIDs ...string) map[string]string { + if existing == nil { + existing = map[string]string{} + } + for _, gatewayID := range gatewayIDs { + existing["gateway/id/"+gatewayID] = gatewayID + } + return existing +} + +func testFirmware(operation, id string) Resource { + return Resource{ + Kind: KindFirmware, + Operation: operation, + Firmware: &firmwareTY.Firmware{ + ID: id, + Description: "fw", + }, + Payload: map[string]interface{}{"description": "fw"}, + } +} + +func testDataRepository(operation, id string) Resource { + return Resource{ + Kind: KindDataRepository, + Operation: operation, + DataRepository: &dataRepoTY.Config{ + ID: id, + Description: "repo", + }, + Payload: map[string]interface{}{"description": "repo"}, + } +} + +func testGateway(operation, id string) Resource { + return Resource{ + Kind: KindGateway, + Operation: operation, + Gateway: &gwTY.Config{ + ID: id, + Description: "gw", + Enabled: true, + }, + Payload: map[string]interface{}{"description": "gw", "enabled": true}, + } +} + +func testNode(operation, gatewayID, nodeID, id string) Resource { + return Resource{ + Kind: KindNode, + Operation: operation, + Node: &nodeTY.Node{ + ID: id, + GatewayID: gatewayID, + NodeID: nodeID, + Name: "n", + }, + Payload: map[string]interface{}{ + "gatewayId": gatewayID, + "nodeId": nodeID, + "name": "n", + }, + } +} + +func testSource(operation, gatewayID, nodeID, sourceID, id string) Resource { + return Resource{ + Kind: KindSource, + Operation: operation, + Src: &sourceTY.Source{ + ID: id, + GatewayID: gatewayID, + NodeID: nodeID, + SourceID: sourceID, + Name: "s", + }, + Payload: map[string]interface{}{ + "gatewayId": gatewayID, + "nodeId": nodeID, + "sourceId": sourceID, + "name": "s", + }, + } +} + +func testField(operation, gatewayID, nodeID, sourceID, fieldID, id string) Resource { + return Resource{ + Kind: KindField, + Operation: operation, + Field: &fieldTY.Field{ + ID: id, + GatewayID: gatewayID, + NodeID: nodeID, + SourceID: sourceID, + FieldID: fieldID, + Name: "f", + }, + Payload: map[string]interface{}{ + "gatewayId": gatewayID, + "nodeId": nodeID, + "sourceId": sourceID, + "fieldId": fieldID, + "name": "f", + }, + } +} + +func TestApplyAddFieldLeavesIDEmpty(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "source/gw1/n1/s1/": "source-id", + }} + resource := Resource{ + Kind: KindField, + Operation: OperationAdd, + Field: &fieldTY.Field{ + GatewayID: "gw1", + NodeID: "n1", + SourceID: "s1", + FieldID: "temp", + Name: "Temperature", + }, + } + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{resource}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Empty(t, client.saved[0].Field.ID) + assertApplyRow(t, out.String(), "field: gw1.n1.s1.temp", "add", "ok") +} + +func TestApplyAddNewResource(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.NotEmpty(t, client.saved[0].Node.ID) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "ok") + assert.Empty(t, client.deleted) +} + +func TestApplyAddExistingFails(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "node/gw1/n1//": "existing-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "failed: already exists") +} + +func TestApplyReplaceFromFileFlag(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1")} + resource := testNode(OperationAdd, "gw1", "n1", "") + resource.Replace = true + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{resource}, false, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"existing-id"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "existing-id", client.saved[0].Node.ID) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "replaced") +} + +func TestApplyAddExistingReplace(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, true, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"existing-id"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "existing-id", client.saved[0].Node.ID) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "replaced") +} + +func TestApplyReplaceFieldKeepsExistingID(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "source/gw1/n1/s1/": "source-id", + "field/gw1/n1/s1/temp": "field-id", + }} + resource := Resource{ + Kind: KindField, + Operation: OperationAdd, + Field: &fieldTY.Field{ + GatewayID: "gw1", + NodeID: "n1", + SourceID: "s1", + FieldID: "temp", + Name: "Temperature", + }, + } + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{resource}, true, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"field-id"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "field-id", client.saved[0].Field.ID) + assertApplyRow(t, out.String(), "field: gw1.n1.s1.temp", "add", "replaced") +} + +func TestApplyReplaceKeepsDeletedIDWhenFileHasDifferentID(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "file-id")}, true, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"existing-id"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "existing-id", client.saved[0].Node.ID) +} + +func TestApplyUpdateMissingFails(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationMerge, "gw1", "n1", "")}, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "node: gw1.n1", "merge", "failed: not found") +} + +func TestApplyUpdateExisting(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationMerge, "gw1", "n1", "")}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Equal(t, "existing-id", client.saved[0].Node.ID) + assert.Empty(t, client.deleted) + assertApplyRow(t, out.String(), "node: gw1.n1", "merge", "ok") +} + +func TestApplyUpdateMergesExistingFields(t *testing.T) { + client := &fakeClient{ + existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1"), + existingJSON: map[string][]byte{ + "existing-id": []byte(`{"id":"existing-id","gatewayId":"gw1","nodeId":"n1","name":"old","labels":{"keep":"yes","room":"kitchen"}}`), + }, + } + resource := testNode(OperationMerge, "gw1", "n1", "") + resource.Payload = map[string]interface{}{ + "gatewayId": "gw1", + "nodeId": "n1", + "name": "new", + "labels": map[string]interface{}{ + "room": "living", + }, + } + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{resource}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Equal(t, "existing-id", client.saved[0].Node.ID) + assert.Equal(t, "new", client.saved[0].Node.Name) + assert.Equal(t, "yes", client.saved[0].Node.Labels["keep"]) + assert.Equal(t, "living", client.saved[0].Node.Labels["room"]) +} + +func TestApplyUpdateKeepsExistingIDWhenFileHasDifferentID(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationMerge, "gw1", "n1", "file-id")}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Equal(t, "existing-id", client.saved[0].Node.ID) + assert.Empty(t, client.deleted) +} + +func TestApplyParentSaveFailureSkipsChild(t *testing.T) { + client := &fakeClient{saveErr: fmt.Errorf("boom")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{ + testGateway(OperationAdd, "gw1"), + testNode(OperationAdd, "gw1", "n1", ""), + }, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "gateway: gw1", "add", "failed: boom") + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "failed: parent gateway gw1 is not present") +} + +func TestApplyReplaceSaveFailureMentionsDeleted(t *testing.T) { + client := &fakeClient{ + existing: withGateway(map[string]string{ + "node/gw1/n1//": "existing-id", + }, "gw1"), + saveErr: fmt.Errorf("disk full"), + } + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, true, false, out, errOut) + require.Error(t, err) + assert.Equal(t, []string{"existing-id"}, client.deleted) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "deleted existing resource, but recreate failed") +} + +func TestApplyDeleteMissingContinues(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{ + testNode(OperationDelete, "gw1", "n1", ""), + testNode(OperationAdd, "gw1", "n2", ""), + }, false, false, out, errOut) + require.NoError(t, err) + assert.Empty(t, client.deleted) + assert.Empty(t, errOut.String()) + assertApplyRow(t, out.String(), "node: gw1.n1", "delete", "not available") + assertApplyRow(t, out.String(), "node: gw1.n2", "add", "ok") + require.Len(t, client.saved, 1) +} + +func TestApplyDeleteMissingDryRunContinues(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationDelete, "gw1", "n1", "")}, false, true, out, errOut) + require.NoError(t, err) + assert.Empty(t, client.deleted) + assert.Empty(t, errOut.String()) + assertApplyRow(t, out.String(), "node: gw1.n1", "delete", "not available") +} + +func TestApplyDeleteExisting(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "node/gw1/n1//": "existing-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationDelete, "gw1", "n1", "")}, false, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"existing-id"}, client.deleted) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "node: gw1.n1", "delete", "ok") +} + +func TestApplyDryRunDoesNotMutate(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, false, true, out, errOut) + require.NoError(t, err) + assert.Empty(t, client.saved) + assert.Empty(t, client.deleted) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "dry-run") +} + +func TestApplyDryRunDetectsExistingAdd(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "node/gw1/n1//": "existing-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, false, true, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assert.Empty(t, client.deleted) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "failed: already exists") +} + +func TestApplyLookupError(t *testing.T) { + client := &fakeClient{findErr: fmt.Errorf("server down")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, false, true, out, errOut) + require.Error(t, err) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "failed: lookup failed") +} + +func TestRunApplyFromStdin(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + in := strings.NewReader(`kind: node +operation: add +gatewayId: gw1 +nodeId: n1 +name: From stdin +`) + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := runApply(client, []string{"-"}, false, true, in, out, errOut) + require.NoError(t, err) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "dry-run") +} + +func TestApplyAddNodeFailsWhenGatewayMissing(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationAdd, "gw1", "n1", "")}, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "failed: parent gateway gw1 is not present") +} + +func TestApplyAddSourceFailsWhenNodeMissing(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testSource(OperationAdd, "gw1", "n1", "s1", "")}, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "source: gw1.n1.s1", "add", "failed: parent node gw1.n1 is not present") +} + +func TestApplyAddFieldFailsWhenSourceMissing(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "node-id", + }, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testField(OperationAdd, "gw1", "n1", "s1", "temp", "")}, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "field: gw1.n1.s1.temp", "add", "failed: parent source gw1.n1.s1 is not present") +} + +func TestApplyUsesParentAddedEarlierInFile(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{ + testNode(OperationAdd, "gw1", "n1", ""), + testSource(OperationAdd, "gw1", "n1", "s1", ""), + testField(OperationAdd, "gw1", "n1", "s1", "temp", ""), + }, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 3) + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "ok") + assertApplyRow(t, out.String(), "source: gw1.n1.s1", "add", "ok") + assertApplyRow(t, out.String(), "field: gw1.n1.s1.temp", "add", "ok") +} + +func TestApplyFailsWhenParentDeletedEarlierInFile(t *testing.T) { + client := &fakeClient{existing: withGateway(map[string]string{ + "node/gw1/n1//": "node-id", + }, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{ + testNode(OperationDelete, "gw1", "n1", ""), + testSource(OperationAdd, "gw1", "n1", "s1", ""), + }, false, false, out, errOut) + require.Error(t, err) + assertApplyRow(t, out.String(), "node: gw1.n1", "delete", "ok") + assertApplyRow(t, out.String(), "source: gw1.n1.s1", "add", "failed: parent node gw1.n1 is not present") + assert.Equal(t, []string{"node-id"}, client.deleted) + assert.Empty(t, client.saved) +} + +func TestApplyAddGateway(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testGateway(OperationAdd, "gw1")}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Equal(t, "gw1", client.saved[0].Gateway.ID) + assertApplyRow(t, out.String(), "gateway: gw1", "add", "ok") +} + +func TestApplyReplaceGatewayKeepsID(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testGateway(OperationAdd, "gw1")}, true, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"gw1"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "gw1", client.saved[0].Gateway.ID) + assertApplyRow(t, out.String(), "gateway: gw1", "add", "replaced") +} + +func TestApplyDeleteGateway(t *testing.T) { + client := &fakeClient{existing: withGateway(nil, "gw1")} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testGateway(OperationDelete, "gw1")}, false, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"gw1"}, client.deleted) + assertApplyRow(t, out.String(), "gateway: gw1", "delete", "ok") +} + +func TestApplyNodeUsesGatewayAddedEarlierInFile(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{ + testGateway(OperationAdd, "gw1"), + testNode(OperationAdd, "gw1", "n1", ""), + }, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 2) + assertApplyRow(t, out.String(), "gateway: gw1", "add", "ok") + assertApplyRow(t, out.String(), "node: gw1.n1", "add", "ok") +} + +func TestApplyAddFirmwareAndDataRepository(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{ + testFirmware(OperationAdd, "stm32-app"), + testDataRepository(OperationAdd, "ota_stm32_ab"), + }, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 2) + assertApplyRow(t, out.String(), "firmware: stm32-app", "add", "ok") + assertApplyRow(t, out.String(), "data-repository: ota_stm32_ab", "add", "ok") +} + +func TestApplyReplaceFirmwareKeepsID(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "firmware/id/stm32-app": "stm32-app", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testFirmware(OperationAdd, "stm32-app")}, true, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"stm32-app"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "stm32-app", client.saved[0].Firmware.ID) + assertApplyRow(t, out.String(), "firmware: stm32-app", "add", "replaced") +} + +func TestApplyUpdateFailsWhenParentMissing(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "node/gw1/n1//": "node-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testNode(OperationMerge, "gw1", "n1", "")}, false, false, out, errOut) + require.Error(t, err) + assert.Empty(t, client.saved) + assertApplyRow(t, out.String(), "node: gw1.n1", "merge", "failed: parent gateway gw1 is not present") +} diff --git a/cmd/client/command/apply/client.go b/cmd/client/command/apply/client.go new file mode 100644 index 00000000..bca623d4 --- /dev/null +++ b/cmd/client/command/apply/client.go @@ -0,0 +1,161 @@ +package apply + +import ( + "fmt" + + "github.com/mycontroller-org/server/v2/cmd/client/api" + "github.com/mycontroller-org/server/v2/pkg/json" +) + +type apiResourceClient struct { + client *api.Client +} + +func newAPIResourceClient(client *api.Client) ResourceClient { + return &apiResourceClient{client: client} +} + +func (c *apiResourceClient) FindGateway(id string) (string, error) { + item, err := c.client.FindGateway(id) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindNode(id, gatewayID, nodeID string) (string, error) { + item, err := c.client.FindNode(id, gatewayID, nodeID) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindSource(id, gatewayID, nodeID, sourceID string) (string, error) { + item, err := c.client.FindSource(id, gatewayID, nodeID, sourceID) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindField(id, gatewayID, nodeID, sourceID, fieldID string) (string, error) { + item, err := c.client.FindField(id, gatewayID, nodeID, sourceID, fieldID) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindFirmware(id string) (string, error) { + item, err := c.client.FindFirmware(id) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindDataRepository(id string) (string, error) { + item, err := c.client.FindDataRepository(id) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) SaveGateway(resource Resource) error { + if resource.Gateway == nil { + return nil + } + return c.client.SaveGateway(resource.Gateway) +} + +func (c *apiResourceClient) SaveNode(resource Resource) error { + if resource.Node == nil { + return nil + } + return c.client.SaveNode(resource.Node) +} + +func (c *apiResourceClient) SaveSource(resource Resource) error { + if resource.Src == nil { + return nil + } + return c.client.SaveSource(resource.Src) +} + +func (c *apiResourceClient) SaveField(resource Resource) error { + if resource.Field == nil { + return nil + } + return c.client.SaveField(resource.Field) +} + +func (c *apiResourceClient) SaveFirmware(resource Resource) error { + if resource.Firmware == nil { + return nil + } + return c.client.SaveFirmware(resource.Firmware) +} + +func (c *apiResourceClient) SaveDataRepository(resource Resource) error { + if resource.DataRepository == nil { + return nil + } + return c.client.SaveDataRepository(resource.DataRepository) +} + +func (c *apiResourceClient) DeleteGateway(ids ...string) error { + return c.client.DeleteGateway(ids...) +} + +func (c *apiResourceClient) DeleteNode(ids ...string) error { + return c.client.DeleteNode(ids...) +} + +func (c *apiResourceClient) DeleteSource(ids ...string) error { + return c.client.DeleteSource(ids...) +} + +func (c *apiResourceClient) DeleteField(ids ...string) error { + return c.client.DeleteField(ids...) +} + +func (c *apiResourceClient) DeleteFirmware(ids ...string) error { + return c.client.DeleteFirmware(ids...) +} + +func (c *apiResourceClient) DeleteDataRepository(ids ...string) error { + return c.client.DeleteDataRepository(ids...) +} + +func (c *apiResourceClient) GetExisting(resource Resource) ([]byte, error) { + var item interface{} + var err error + switch resource.Kind { + case KindGateway: + item, err = c.client.FindGateway(resource.ID()) + case KindNode: + gatewayID, nodeID, _, _ := resource.NaturalKeys() + item, err = c.client.FindNode(resource.ID(), gatewayID, nodeID) + case KindSource: + gatewayID, nodeID, sourceID, _ := resource.NaturalKeys() + item, err = c.client.FindSource(resource.ID(), gatewayID, nodeID, sourceID) + case KindField: + gatewayID, nodeID, sourceID, fieldID := resource.NaturalKeys() + item, err = c.client.FindField(resource.ID(), gatewayID, nodeID, sourceID, fieldID) + case KindFirmware: + item, err = c.client.FindFirmware(resource.ID()) + case KindDataRepository: + item, err = c.client.FindDataRepository(resource.ID()) + default: + return nil, fmt.Errorf("unsupported kind %q", resource.Kind) + } + if err != nil { + return nil, err + } + if item == nil { + return nil, fmt.Errorf("resource is not present") + } + return json.Marshal(item) +} diff --git a/cmd/client/command/apply/cmd.go b/cmd/client/command/apply/cmd.go new file mode 100644 index 00000000..f3455527 --- /dev/null +++ b/cmd/client/command/apply/cmd.go @@ -0,0 +1,140 @@ +package apply + +import ( + "errors" + "fmt" + "io" + "os" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + "github.com/spf13/cobra" +) + +var ( + filenameSlice []string + dryRun bool + replace bool +) + +func init() { + rootCmd.Cmd.AddCommand(applyCmd) + applyCmd.Flags().StringSliceVarP(&filenameSlice, "filename", "f", []string{}, "YAML or JSON file with resources. Use '-' to read from stdin. Repeat for multiple files") + applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate and report actions without changing resources") + applyCmd.Flags().BoolVar(&replace, "replace", false, "if a resource already exists on add, delete it and recreate it with the same id") + _ = applyCmd.MarkFlagRequired("filename") +} + +var applyCmd = &cobra.Command{ + Use: "apply", + Short: "Add, merge, or delete resources from a YAML or JSON file", + SilenceUsage: true, + SilenceErrors: true, + Long: `Apply gateways, nodes, sources, fields, firmware, and data repositories from a YAML or JSON file. + +Each resource must include kind (gateway, node, source, field, firmware, data-repository) and operation (add, merge, delete). +Firmware binary files are not part of apply; upload them with myc upload firmware. +Add fails when the resource already exists, unless --replace is set or the +resource has replace: true. +With replace, the existing resource is deleted and recreated with the same id +so references to that id stay valid. +Add, merge, and replace fail when the parent resource is not present +(node needs gateway, source needs node, field needs source). +A parent added earlier in the same file counts as present. +Delete of a missing resource is reported as not available and the remaining resources are still applied. + +YAML example: + + kind: gateway + operation: add + id: mysensor + description: MySensors USB + enabled: true + --- + kind: node + operation: add + gatewayId: mysensor + nodeId: "1" + name: Living Room + --- + kind: source + operation: merge + gatewayId: mysensor + nodeId: "1" + sourceId: dht + name: DHT Sensor + --- + kind: field + operation: delete + gatewayId: mysensor + nodeId: "1" + sourceId: dht + fieldId: temperature + + # shared kind/operation/replace with an items list + kind: field + operation: add + replace: true + items: + - gatewayId: mysensor + nodeId: "1" + sourceId: dht + fieldId: temperature + name: Temperature + metricType: gauge + unit: °C + +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`, + 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) + if errors.Is(err, ErrApplyFailed) { + os.Exit(1) + } + return err + }, +} + +func runApply(client ResourceClient, filenames []string, replace, dryRun bool, in io.Reader, out, errOut io.Writer) error { + if len(filenames) == 0 { + return fmt.Errorf("must specify at least one --filename") + } + + resources := make([]Resource, 0) + for _, filename := range filenames { + data, source, err := readInput(filename, in) + if err != nil { + return err + } + parsed, err := ParseResources(data, source) + if err != nil { + return err + } + resources = append(resources, parsed...) + } + + return Apply(client, resources, replace, dryRun, out, errOut) +} + +func readInput(filename string, in io.Reader) ([]byte, string, error) { + if filename == "-" { + data, err := io.ReadAll(in) + if err != nil { + return nil, "stdin", fmt.Errorf("failed to read stdin: %w", err) + } + return data, "stdin", nil + } + + data, err := os.ReadFile(filename) + if err != nil { + return nil, filename, fmt.Errorf("failed to read %s: %w", filename, err) + } + return data, filename, nil +} diff --git a/cmd/client/command/apply/merge.go b/cmd/client/command/apply/merge.go new file mode 100644 index 00000000..ba95a00d --- /dev/null +++ b/cmd/client/command/apply/merge.go @@ -0,0 +1,128 @@ +package apply + +import "fmt" + +var arrayMergeKeys = []string{"id", "key", "fieldId", "field", "name", "type", "sourceId"} + +func deepMergeValue(base, overlay interface{}) interface{} { + if overlay == nil { + return base + } + baseMap, baseIsMap := asMap(base) + overlayMap, overlayIsMap := asMap(overlay) + if baseIsMap && overlayIsMap { + return deepMergeMaps(baseMap, overlayMap) + } + baseSlice, baseIsSlice := asSlice(base) + overlaySlice, overlayIsSlice := asSlice(overlay) + if overlayIsSlice { + if baseIsSlice { + return deepMergeSlices(baseSlice, overlaySlice) + } + return overlaySlice + } + return overlay +} + +func deepMergeMaps(base, overlay map[string]interface{}) map[string]interface{} { + out := copyMap(base) + for key, value := range overlay { + if existing, ok := out[key]; ok { + out[key] = deepMergeValue(existing, value) + continue + } + out[key] = value + } + return out +} + +func deepMergeSlices(base, overlay []interface{}) []interface{} { + key := arrayItemKey(overlay) + if key == "" { + key = arrayItemKey(base) + } + if key == "" { + return overlay + } + + out := make([]interface{}, len(base)) + copy(out, base) + index := map[string]int{} + for i, item := range out { + if m, ok := asMap(item); ok { + if id := mapString(m, key); id != "" { + index[id] = i + } + } + } + for _, item := range overlay { + m, ok := asMap(item) + if !ok { + out = append(out, item) + continue + } + id := mapString(m, key) + if id == "" { + out = append(out, item) + continue + } + if pos, found := index[id]; found { + out[pos] = deepMergeValue(out[pos], m) + continue + } + index[id] = len(out) + out = append(out, item) + } + return out +} + +func arrayItemKey(items []interface{}) string { + for _, item := range items { + m, ok := asMap(item) + if !ok { + continue + } + for _, key := range arrayMergeKeys { + if mapString(m, key) != "" { + return key + } + } + } + return "" +} + +func asMap(value interface{}) (map[string]interface{}, bool) { + switch typed := value.(type) { + case map[string]interface{}: + return typed, true + case map[interface{}]interface{}: + out := make(map[string]interface{}, len(typed)) + for key, item := range typed { + out[fmt.Sprint(key)] = item + } + return out, true + default: + return nil, false + } +} + +func asSlice(value interface{}) ([]interface{}, bool) { + switch typed := value.(type) { + case []interface{}: + return typed, true + default: + return nil, false + } +} + +func mapString(m map[string]interface{}, key string) string { + value, ok := m[key] + if !ok || value == nil { + return "" + } + s := fmt.Sprint(value) + if s == "" || s == "" { + return "" + } + return s +} diff --git a/cmd/client/command/apply/merge_test.go b/cmd/client/command/apply/merge_test.go new file mode 100644 index 00000000..33d511e9 --- /dev/null +++ b/cmd/client/command/apply/merge_test.go @@ -0,0 +1,88 @@ +package apply + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeepMergeMapsKeepsMissingKeys(t *testing.T) { + base := map[string]interface{}{ + "name": "old", + "labels": map[string]interface{}{ + "keep": "yes", + "room": "kitchen", + }, + "others": map[string]interface{}{ + "note": "stay", + "nested": map[string]interface{}{ + "a": "1", + "b": "2", + }, + }, + } + overlay := map[string]interface{}{ + "name": "new", + "labels": map[string]interface{}{ + "room": "living", + }, + "others": map[string]interface{}{ + "nested": map[string]interface{}{ + "b": "9", + }, + }, + } + merged, ok := deepMergeValue(base, overlay).(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "new", merged["name"]) + labels := merged["labels"].(map[string]interface{}) + assert.Equal(t, "yes", labels["keep"]) + assert.Equal(t, "living", labels["room"]) + others := merged["others"].(map[string]interface{}) + assert.Equal(t, "stay", others["note"]) + nested := others["nested"].(map[string]interface{}) + assert.Equal(t, "1", nested["a"]) + assert.Equal(t, "9", nested["b"]) +} + +func TestDeepMergeArrayByID(t *testing.T) { + base := []interface{}{ + map[string]interface{}{"id": "a", "name": "old", "keep": "yes"}, + map[string]interface{}{"id": "b", "name": "b"}, + } + overlay := []interface{}{ + map[string]interface{}{"id": "a", "name": "new"}, + map[string]interface{}{"id": "c", "name": "c"}, + } + merged, ok := deepMergeValue(base, overlay).([]interface{}) + require.True(t, ok) + require.Len(t, merged, 3) + first := merged[0].(map[string]interface{}) + assert.Equal(t, "new", first["name"]) + assert.Equal(t, "yes", first["keep"]) + assert.Equal(t, "b", merged[1].(map[string]interface{})["id"]) + assert.Equal(t, "c", merged[2].(map[string]interface{})["id"]) +} + +func TestDeepMergeArrayByField(t *testing.T) { + base := []interface{}{ + map[string]interface{}{"field": "temp", "unit": "C", "name": "old"}, + map[string]interface{}{"field": "hum", "unit": "%"}, + } + overlay := []interface{}{ + map[string]interface{}{"field": "temp", "name": "Temperature"}, + } + merged, ok := deepMergeValue(base, overlay).([]interface{}) + require.True(t, ok) + require.Len(t, merged, 2) + temp := merged[0].(map[string]interface{}) + assert.Equal(t, "Temperature", temp["name"]) + assert.Equal(t, "C", temp["unit"]) + assert.Equal(t, "hum", merged[1].(map[string]interface{})["field"]) +} + +func TestDeepMergeScalarArrayReplaces(t *testing.T) { + merged := deepMergeValue([]interface{}{"a", "b"}, []interface{}{"c"}) + assert.Equal(t, []interface{}{"c"}, merged) +} diff --git a/cmd/client/command/apply/parse.go b/cmd/client/command/apply/parse.go new file mode 100644 index 00000000..e1a4dbdf --- /dev/null +++ b/cmd/client/command/apply/parse.go @@ -0,0 +1,559 @@ +package apply + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/mycontroller-org/server/v2/pkg/json" + dataRepoTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + "github.com/mycontroller-org/server/v2/pkg/utils" + gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + "gopkg.in/yaml.v3" +) + +const ( + KindGateway = "gateway" + KindNode = "node" + KindSource = "source" + KindField = "field" + KindFirmware = "firmware" + KindDataRepository = "data-repository" + + OperationAdd = "add" + OperationMerge = "merge" + OperationDelete = "delete" +) + +// Resource is one node, source, or field from a YAML/JSON file. +type Resource struct { + Kind string + Operation string + Replace bool + Index int + Source string + Gateway *gwTY.Config + Node *nodeTY.Node + Src *sourceTY.Source + Field *fieldTY.Field + Firmware *firmwareTY.Firmware + DataRepository *dataRepoTY.Config + Payload map[string]interface{} +} + +func (r Resource) ID() string { + switch r.Kind { + case KindGateway: + if r.Gateway != nil { + return r.Gateway.ID + } + case KindNode: + if r.Node != nil { + return r.Node.ID + } + case KindSource: + if r.Src != nil { + return r.Src.ID + } + case KindField: + if r.Field != nil { + return r.Field.ID + } + case KindFirmware: + if r.Firmware != nil { + return r.Firmware.ID + } + case KindDataRepository: + if r.DataRepository != nil { + return r.DataRepository.ID + } + } + return "" +} + +func (r Resource) SetID(id string) { + switch r.Kind { + case KindGateway: + if r.Gateway != nil { + r.Gateway.ID = id + } + case KindNode: + if r.Node != nil { + r.Node.ID = id + } + case KindSource: + if r.Src != nil { + r.Src.ID = id + } + case KindField: + if r.Field != nil { + r.Field.ID = id + } + case KindFirmware: + if r.Firmware != nil { + r.Firmware.ID = id + } + case KindDataRepository: + if r.DataRepository != nil { + r.DataRepository.ID = id + } + } +} + +func (r Resource) NaturalKeys() (gatewayID, nodeID, sourceID, fieldID string) { + switch r.Kind { + case KindGateway: + if r.Gateway != nil { + return r.Gateway.ID, "", "", "" + } + case KindNode: + if r.Node != nil { + return r.Node.GatewayID, r.Node.NodeID, "", "" + } + case KindSource: + if r.Src != nil { + return r.Src.GatewayID, r.Src.NodeID, r.Src.SourceID, "" + } + case KindField: + if r.Field != nil { + return r.Field.GatewayID, r.Field.NodeID, r.Field.SourceID, r.Field.FieldID + } + case KindFirmware: + if r.Firmware != nil { + return r.Firmware.ID, "", "", "" + } + case KindDataRepository: + if r.DataRepository != nil { + return r.DataRepository.ID, "", "", "" + } + } + return "", "", "", "" +} + +func (r Resource) TableResource() string { + gatewayID, nodeID, sourceID, fieldID := r.NaturalKeys() + parts := make([]string, 0, 4) + if gatewayID != "" { + parts = append(parts, gatewayID) + } + if nodeID != "" { + parts = append(parts, nodeID) + } + if sourceID != "" { + parts = append(parts, sourceID) + } + if fieldID != "" { + parts = append(parts, fieldID) + } + if len(parts) > 0 { + return fmt.Sprintf("%s: %s", r.Kind, strings.Join(parts, ".")) + } + if id := r.ID(); id != "" { + return fmt.Sprintf("%s: %s", r.Kind, id) + } + return r.Kind +} + +func (r Resource) Identity() string { + id := r.ID() + gatewayID, nodeID, sourceID, fieldID := r.NaturalKeys() + parts := make([]string, 0, 4) + if gatewayID != "" { + parts = append(parts, gatewayID) + } + if nodeID != "" { + parts = append(parts, nodeID) + } + if sourceID != "" { + parts = append(parts, sourceID) + } + if fieldID != "" { + parts = append(parts, fieldID) + } + natural := strings.Join(parts, ".") + switch { + case id != "" && natural != "": + return fmt.Sprintf("%s %s (id=%s)", r.Kind, natural, id) + case id != "": + return fmt.Sprintf("%s id=%s", r.Kind, id) + case natural != "": + return fmt.Sprintf("%s %s", r.Kind, natural) + default: + return fmt.Sprintf("%s #%d", r.Kind, r.Index+1) + } +} + +func (r Resource) Validate() error { + if r.Kind == "" { + return fmt.Errorf("kind is required") + } + if r.Operation == "" { + return fmt.Errorf("operation is required") + } + + gatewayID, nodeID, sourceID, fieldID := r.NaturalKeys() + hasID := r.ID() != "" + + switch r.Kind { + case KindGateway: + if !hasID { + return fmt.Errorf("gateway requires id") + } + case KindFirmware: + if !hasID { + return fmt.Errorf("firmware requires id") + } + case KindDataRepository: + if !hasID { + return fmt.Errorf("data-repository requires id") + } + case KindNode: + if r.Operation != OperationDelete && (gatewayID == "" || nodeID == "") { + return fmt.Errorf("node requires gatewayId and nodeId") + } + if r.Operation == OperationDelete && !hasID && (gatewayID == "" || nodeID == "") { + return fmt.Errorf("delete node requires id or gatewayId+nodeId") + } + case KindSource: + if r.Operation != OperationDelete && (gatewayID == "" || nodeID == "" || sourceID == "") { + return fmt.Errorf("source requires gatewayId, nodeId and sourceId") + } + if r.Operation == OperationDelete && !hasID && (gatewayID == "" || nodeID == "" || sourceID == "") { + return fmt.Errorf("delete source requires id or gatewayId+nodeId+sourceId") + } + case KindField: + if r.Operation != OperationDelete && (gatewayID == "" || nodeID == "" || sourceID == "" || fieldID == "") { + return fmt.Errorf("field requires gatewayId, nodeId, sourceId and fieldId") + } + if r.Operation == OperationDelete && !hasID && (gatewayID == "" || nodeID == "" || sourceID == "" || fieldID == "") { + return fmt.Errorf("delete field requires id or gatewayId+nodeId+sourceId+fieldId") + } + default: + return fmt.Errorf("unsupported kind %q", r.Kind) + } + + switch r.Operation { + case OperationAdd, OperationMerge, OperationDelete: + default: + return fmt.Errorf("unsupported operation %q", r.Operation) + } + return nil +} + +// ParseResources reads one or more resources from YAML or JSON. +// Supported shapes: a single object, a list, or YAML documents separated by ---. +func ParseResources(data []byte, source string) ([]Resource, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return nil, fmt.Errorf("no resources found in %s", sourceName(source)) + } + + var docs []map[string]interface{} + var err error + if looksLikeJSON(trimmed) { + docs, err = parseJSONDocuments(trimmed) + } else { + docs, err = parseYAMLDocuments(trimmed) + } + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", sourceName(source), err) + } + if len(docs) == 0 { + return nil, fmt.Errorf("no resources found in %s", sourceName(source)) + } + + resources := make([]Resource, 0, len(docs)) + for i, doc := range docs { + parsed, err := resourcesFromMap(doc, len(resources), source) + if err != nil { + return nil, fmt.Errorf("%s: resource %d: %w", sourceName(source), i+1, err) + } + resources = append(resources, parsed...) + } + return resources, nil +} + +func looksLikeJSON(data []byte) bool { + if len(data) == 0 { + return false + } + switch data[0] { + case '{', '[': + return true + default: + return false + } +} + +func parseJSONDocuments(data []byte) ([]map[string]interface{}, error) { + var raw interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return documentsFromValue(raw) +} + +func parseYAMLDocuments(data []byte) ([]map[string]interface{}, error) { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var docs []map[string]interface{} + for { + var raw interface{} + err := decoder.Decode(&raw) + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if raw == nil { + continue + } + parsed, err := documentsFromValue(raw) + if err != nil { + return nil, err + } + docs = append(docs, parsed...) + } + return docs, nil +} + +func documentsFromValue(raw interface{}) ([]map[string]interface{}, error) { + switch value := raw.(type) { + case map[string]interface{}: + return []map[string]interface{}{value}, nil + case []interface{}: + docs := make([]map[string]interface{}, 0, len(value)) + for i, item := range value { + doc, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("item %d is not an object", i+1) + } + docs = append(docs, doc) + } + return docs, nil + default: + return nil, fmt.Errorf("expected an object or a list of objects, got %T", raw) + } +} + +func resourcesFromMap(doc map[string]interface{}, index int, source string) ([]Resource, error) { + rawItems, hasItems := doc["items"] + if !hasItems || rawItems == nil { + resource, err := resourceFromMap(doc, index, source) + if err != nil { + return nil, err + } + return []Resource{resource}, nil + } + + items, err := asObjectList(rawItems) + if err != nil { + return nil, fmt.Errorf("items: %w", err) + } + if len(items) == 0 { + return nil, fmt.Errorf("items must not be empty") + } + + defaults := copyMap(doc) + delete(defaults, "items") + + resources := make([]Resource, 0, len(items)) + for i, item := range items { + merged := copyMap(defaults) + for key, value := range item { + merged[key] = value + } + resource, err := resourceFromMap(merged, index+i, source) + if err != nil { + return nil, fmt.Errorf("items[%d]: %w", i, err) + } + resources = append(resources, resource) + } + return resources, nil +} + +func asObjectList(raw interface{}) ([]map[string]interface{}, error) { + list, ok := raw.([]interface{}) + if !ok { + return nil, fmt.Errorf("must be a list of objects") + } + items := make([]map[string]interface{}, 0, len(list)) + for i, item := range list { + doc, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("item %d is not an object", i+1) + } + items = append(items, doc) + } + return items, nil +} + +func resourceFromMap(doc map[string]interface{}, index int, source string) (Resource, error) { + kind, err := effectiveKind(doc) + if err != nil { + return Resource{}, err + } + operation, err := normalizeOperation(stringValue(doc, "operation")) + if err != nil { + return Resource{}, err + } + + payload := copyMap(doc) + delete(payload, "kind") + delete(payload, "operation") + delete(payload, "replace") + delete(payload, "items") + + resource := Resource{ + Kind: kind, + Operation: operation, + Replace: boolValue(doc, "replace"), + Index: index, + Source: source, + Payload: payload, + } + + switch kind { + case KindGateway: + gateway := &gwTY.Config{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, gateway); err != nil { + return Resource{}, fmt.Errorf("invalid gateway: %w", err) + } + resource.Gateway = gateway + case KindNode: + node := &nodeTY.Node{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, node); err != nil { + return Resource{}, fmt.Errorf("invalid node: %w", err) + } + resource.Node = node + case KindSource: + src := &sourceTY.Source{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, src); err != nil { + return Resource{}, fmt.Errorf("invalid source: %w", err) + } + resource.Src = src + case KindField: + field := &fieldTY.Field{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, field); err != nil { + return Resource{}, fmt.Errorf("invalid field: %w", err) + } + resource.Field = field + case KindFirmware: + firmware := &firmwareTY.Firmware{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, firmware); err != nil { + return Resource{}, fmt.Errorf("invalid firmware: %w", err) + } + resource.Firmware = firmware + case KindDataRepository: + item := &dataRepoTY.Config{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, item); err != nil { + return Resource{}, fmt.Errorf("invalid data-repository: %w", err) + } + resource.DataRepository = item + } + + if err := resource.Validate(); err != nil { + return Resource{}, err + } + return resource, nil +} + +func effectiveKind(doc map[string]interface{}) (string, error) { + if hasNonEmpty(doc, "fieldId") { + return KindField, nil + } + return normalizeKind(stringValue(doc, "kind")) +} + +func normalizeKind(kind string) (string, error) { + switch strings.ToLower(strings.TrimSpace(kind)) { + case KindGateway, "gw", "gateways": + return KindGateway, nil + case KindNode, "nodes": + return KindNode, nil + case KindSource, "sources": + return KindSource, nil + case KindField, "fields": + return KindField, nil + case KindFirmware, "firmwares", "fw": + return KindFirmware, nil + case KindDataRepository, "datarepository", "data-repo", "data-repositories", "datarepo": + return KindDataRepository, nil + case "": + return "", fmt.Errorf("kind is required") + default: + return "", fmt.Errorf("unsupported kind %q (supported: gateway, node, source, field, firmware, data-repository)", kind) + } +} + +func normalizeOperation(operation string) (string, error) { + switch strings.ToLower(strings.TrimSpace(operation)) { + case OperationAdd, "create": + return OperationAdd, nil + case OperationMerge, "update": + return OperationMerge, nil + case OperationDelete, "remove": + return OperationDelete, nil + case "": + return "", fmt.Errorf("operation is required") + default: + return "", fmt.Errorf("unsupported operation %q (supported: add, merge, delete)", operation) + } +} + +func stringValue(doc map[string]interface{}, key string) string { + value, ok := doc[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + default: + return fmt.Sprintf("%v", typed) + } +} + +func hasNonEmpty(doc map[string]interface{}, key string) bool { + return strings.TrimSpace(stringValue(doc, key)) != "" +} + +func boolValue(doc map[string]interface{}, key string) bool { + value, ok := doc[key] + if !ok || value == nil { + return false + } + switch typed := value.(type) { + case bool: + return typed + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "true", "yes", "1": + return true + default: + return false + } + default: + return false + } +} + +func copyMap(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func sourceName(source string) string { + if source == "" { + return "input" + } + return source +} diff --git a/cmd/client/command/apply/parse_test.go b/cmd/client/command/apply/parse_test.go new file mode 100644 index 00000000..f294e363 --- /dev/null +++ b/cmd/client/command/apply/parse_test.go @@ -0,0 +1,353 @@ +package apply + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseResourcesGateway(t *testing.T) { + data := []byte(` +kind: gateway +operation: add +id: mysensor +description: MySensors USB +enabled: true +provider: + type: mysensors_v2 +`) + resources, err := ParseResources(data, "gateway.yaml") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, KindGateway, resources[0].Kind) + assert.Equal(t, OperationAdd, resources[0].Operation) + require.NotNil(t, resources[0].Gateway) + assert.Equal(t, "mysensor", resources[0].Gateway.ID) + assert.Equal(t, "MySensors USB", resources[0].Gateway.Description) + assert.True(t, resources[0].Gateway.Enabled) + assert.Equal(t, "mysensors_v2", resources[0].Gateway.Provider["type"]) +} + +func TestParseResourcesFirmwareAndDataRepository(t *testing.T) { + data := []byte(` +kind: firmware +operation: add +id: stm32-app +description: Slot A image +labels: + ms_flash_slot: A +--- +kind: data-repository +operation: add +id: ota_stm32_ab +description: STM32 A/B OTA policy +readOnly: true +data: + disabled: false +`) + resources, err := ParseResources(data, "fw.yaml") + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, KindFirmware, resources[0].Kind) + assert.Equal(t, "stm32-app", resources[0].Firmware.ID) + assert.Equal(t, "Slot A image", resources[0].Firmware.Description) + assert.Equal(t, "A", resources[0].Firmware.Labels["ms_flash_slot"]) + assert.Equal(t, KindDataRepository, resources[1].Kind) + assert.Equal(t, "ota_stm32_ab", resources[1].DataRepository.ID) + assert.True(t, resources[1].DataRepository.ReadOnly) + assert.Equal(t, false, resources[1].DataRepository.Data["disabled"]) +} + +func TestParseResourcesYAMLSingle(t *testing.T) { + data := []byte(` +kind: node +operation: add +gatewayId: gw1 +nodeId: n1 +name: Living Room +labels: + room: living +`) + resources, err := ParseResources(data, "node.yaml") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, KindNode, resources[0].Kind) + assert.Equal(t, OperationAdd, resources[0].Operation) + require.NotNil(t, resources[0].Node) + assert.Equal(t, "gw1", resources[0].Node.GatewayID) + assert.Equal(t, "n1", resources[0].Node.NodeID) + assert.Equal(t, "Living Room", resources[0].Node.Name) + assert.Equal(t, "living", resources[0].Node.Labels["room"]) +} + +func TestParseResourcesYAMLMultiDoc(t *testing.T) { + data := []byte(` +kind: node +operation: add +gatewayId: gw1 +nodeId: n1 +name: Node 1 +--- +kind: source +operation: update +gatewayId: gw1 +nodeId: n1 +sourceId: s1 +name: Temperature +--- +kind: field +operation: delete +gatewayId: gw1 +nodeId: n1 +sourceId: s1 +fieldId: temp +`) + resources, err := ParseResources(data, "mixed.yaml") + require.NoError(t, err) + require.Len(t, resources, 3) + assert.Equal(t, KindNode, resources[0].Kind) + assert.Equal(t, OperationAdd, resources[0].Operation) + assert.Equal(t, KindSource, resources[1].Kind) + assert.Equal(t, OperationMerge, resources[1].Operation) + assert.Equal(t, "s1", resources[1].Src.SourceID) + assert.Equal(t, KindField, resources[2].Kind) + assert.Equal(t, OperationDelete, resources[2].Operation) + assert.Equal(t, "temp", resources[2].Field.FieldID) +} + +func TestParseResourcesYAMLList(t *testing.T) { + data := []byte(` +- kind: node + operation: create + gatewayId: gw1 + nodeId: "1" + name: Node 1 +- kind: sources + operation: add + gatewayId: gw1 + nodeId: "1" + sourceId: s1 + name: Source 1 +`) + resources, err := ParseResources(data, "list.yaml") + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, KindNode, resources[0].Kind) + assert.Equal(t, OperationAdd, resources[0].Operation) + assert.Equal(t, "1", resources[0].Node.NodeID) + assert.Equal(t, KindSource, resources[1].Kind) + assert.Equal(t, "s1", resources[1].Src.SourceID) +} + +func TestParseResourcesJSONArray(t *testing.T) { + data := []byte(`[ + {"kind":"node","operation":"add","gatewayId":"gw1","nodeId":"n1","name":"N1"}, + {"kind":"field","operation":"remove","id":"field-1"} +]`) + resources, err := ParseResources(data, "resources.json") + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, KindNode, resources[0].Kind) + assert.Equal(t, KindField, resources[1].Kind) + assert.Equal(t, OperationDelete, resources[1].Operation) + assert.Equal(t, "field-1", resources[1].Field.ID) +} + +func TestParseResourcesJSONObject(t *testing.T) { + data := []byte(`{"kind":"source","operation":"update","gatewayId":"gw1","nodeId":"n1","sourceId":"s1","name":"S1"}`) + resources, err := ParseResources(data, "source.json") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, KindSource, resources[0].Kind) + assert.Equal(t, OperationMerge, resources[0].Operation) + assert.Equal(t, "S1", resources[0].Src.Name) +} + +func TestParseResourcesValidation(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "missing kind", + input: "operation: add\ngatewayId: gw1\nnodeId: n1\n", + wantErr: "kind is required", + }, + { + name: "missing operation", + input: "kind: node\ngatewayId: gw1\nnodeId: n1\n", + wantErr: "operation is required", + }, + { + name: "unsupported kind", + input: "kind: task\noperation: add\nid: t1\n", + wantErr: "unsupported kind", + }, + { + name: "gateway missing id", + input: "kind: gateway\noperation: add\ndescription: usb\n", + wantErr: "gateway requires id", + }, + { + name: "firmware missing id", + input: "kind: firmware\noperation: add\ndescription: img\n", + wantErr: "firmware requires id", + }, + { + name: "data-repository missing id", + input: "kind: data-repo\noperation: add\ndescription: repo\n", + wantErr: "data-repository requires id", + }, + { + name: "unsupported operation", + input: "kind: node\noperation: patch\ngatewayId: gw1\nnodeId: n1\n", + wantErr: "unsupported operation", + }, + { + name: "node missing keys", + input: "kind: node\noperation: add\nname: only-name\n", + wantErr: "node requires gatewayId and nodeId", + }, + { + name: "empty file", + input: " \n", + wantErr: "no resources found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseResources([]byte(tt.input), "test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestParseResourcesNumericIDs(t *testing.T) { + data := []byte(` +kind: field +operation: add +gatewayId: gw1 +nodeId: 1 +sourceId: 2 +fieldId: 3 +name: Temperature +`) + resources, err := ParseResources(data, "numeric.yaml") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, "1", resources[0].Field.NodeID) + assert.Equal(t, "2", resources[0].Field.SourceID) + assert.Equal(t, "3", resources[0].Field.FieldID) +} + +func TestParseResourcesItemsList(t *testing.T) { + data := []byte(` +kind: source +operation: add +replace: true +items: + - gatewayId: mysensor + nodeId: "1" + sourceId: dht + fieldId: temperature + name: Temperature + metricType: gauge + unit: °C + labels: + location: living-room + - gatewayId: mysensor + nodeId: "1" + sourceId: dht + fieldId: humidity + name: Humidity + unit: "%" +`) + resources, err := ParseResources(data, "items.yaml") + require.NoError(t, err) + require.Len(t, resources, 2) + + assert.Equal(t, KindField, resources[0].Kind) + assert.Equal(t, OperationAdd, resources[0].Operation) + assert.True(t, resources[0].Replace) + require.NotNil(t, resources[0].Field) + assert.Equal(t, "mysensor", resources[0].Field.GatewayID) + assert.Equal(t, "1", resources[0].Field.NodeID) + assert.Equal(t, "dht", resources[0].Field.SourceID) + assert.Equal(t, "temperature", resources[0].Field.FieldID) + assert.Equal(t, "Temperature", resources[0].Field.Name) + assert.Equal(t, "gauge", resources[0].Field.MetricType) + assert.Equal(t, "°C", resources[0].Field.Unit) + assert.Equal(t, "living-room", resources[0].Field.Labels["location"]) + + assert.Equal(t, KindField, resources[1].Kind) + assert.Equal(t, "humidity", resources[1].Field.FieldID) + assert.True(t, resources[1].Replace) +} + +func TestParseResourcesItemsInheritDefaults(t *testing.T) { + data := []byte(` +kind: field +operation: add +gatewayId: mysensor +nodeId: "1" +sourceId: dht +items: + - fieldId: temperature + name: Temperature + - fieldId: humidity + name: Humidity +`) + resources, err := ParseResources(data, "defaults.yaml") + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, "mysensor", resources[0].Field.GatewayID) + assert.Equal(t, "dht", resources[0].Field.SourceID) + assert.Equal(t, "temperature", resources[0].Field.FieldID) + assert.Equal(t, "humidity", resources[1].Field.FieldID) + assert.Equal(t, "Humidity", resources[1].Field.Name) +} + +func TestParseResourcesItemsSourceWithoutFieldID(t *testing.T) { + data := []byte(` +kind: source +operation: add +items: + - gatewayId: gw1 + nodeId: n1 + sourceId: s1 + name: DHT +`) + resources, err := ParseResources(data, "sources.yaml") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, KindSource, resources[0].Kind) + assert.False(t, resources[0].Replace) + assert.Equal(t, "s1", resources[0].Src.SourceID) + assert.Equal(t, "DHT", resources[0].Src.Name) +} + +func TestParseResourcesItemsEmpty(t *testing.T) { + _, err := ParseResources([]byte("kind: source\noperation: add\nitems: []\n"), "empty.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "items must not be empty") +} + +func TestResourceIdentity(t *testing.T) { + resources, err := ParseResources([]byte(` +kind: field +operation: add +id: abc +gatewayId: gw1 +nodeId: n1 +sourceId: s1 +fieldId: temp +`), "id.yaml") + require.NoError(t, err) + assert.True(t, strings.Contains(resources[0].Identity(), "gw1.n1.s1.temp")) + assert.True(t, strings.Contains(resources[0].Identity(), "id=abc")) +} diff --git a/cmd/client/command/set/cmd.go b/cmd/client/command/set/cmd.go index 0e154610..6aeb2c79 100644 --- a/cmd/client/command/set/cmd.go +++ b/cmd/client/command/set/cmd.go @@ -2,49 +2,125 @@ package set import ( "fmt" + "os" + "regexp" + "strings" 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" ) +var keyPathPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$`) + const ( - QuickIDPrefixGateway = "gateway" - QuickIDPrefixNode = "node" - QuickIDPrefixField = "field" + QuickIDPrefixField = "field" ) -// var ( -// labelSlice []string -// ) +var ( + setFile string + setPath string +) func init() { rootCmd.Cmd.AddCommand(setCmd) - // setCmd.PersistentFlags().StringSliceVarP(&labelSlice, "label", "l", []string{}, "filter the resource by label. comma separated or repeated label=value") + setCmd.PersistentFlags().StringVarP(&setFile, "file", "f", "", "read the value from this file") + setCmd.PersistentFlags().StringVar(&setPath, "path", "", "nested field path, for example formatter.onReceive") } var setCmd = &cobra.Command{ Use: "set", - Short: "Sets the value to the given resource(s)", + 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 + +Set a live field value with a separate command: + + myc set value field gw1.1.1.V_CUSTOM 23.5 +`, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, } -func executeSetCmd(quickIdPrefix, keyPath string, resources []string, payload string) { +func executeSetFieldValue(resources []string, payload string) error { client := rootCmd.GetClient() - actions := []webHandlerTY.ActionConfig{} + actions := make([]webHandlerTY.ActionConfig, 0, len(resources)) for _, resource := range resources { - action := webHandlerTY.ActionConfig{ - Resource: fmt.Sprintf("%s:%s", quickIdPrefix, resource), - KeyPath: keyPath, + actions = append(actions, webHandlerTY.ActionConfig{ + Resource: fmt.Sprintf("%s:%s", QuickIDPrefixField, resource), Payload: payload, + }) + } + if err := client.ExecuteAction(actions); err != nil { + return fmt.Errorf("error: %s", err) + } + for _, resource := range resources { + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "set field: %s\n", resource) + } + return nil +} + +func executeSetPath(kind string, selectors []string, keyPath, value string) error { + client := rootCmd.GetClient() + failed := 0 + for _, selector := range selectors { + if err := client.SetResourcePath(kind, selector, keyPath, value, setFile != ""); err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error: %s %s: %s\n", kind, selector, err) + failed++ + continue } - actions = append(actions, action) + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "set %s: %s %s\n", kind, selector, keyPath) } - err := client.ExecuteAction(actions) - if err != nil { - _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s", err.Error()) + if failed > 0 { + return fmt.Errorf("failed to set %d resource(s)", failed) } + return nil +} + +func readSetValue(filename, inline string) (string, error) { + if filename != "" { + data, err := os.ReadFile(filename) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", filename, err) + } + return string(data), nil + } + return inline, nil +} + +func parseSetArgs(args []string, file, path string) (selectors []string, keyPath, value string, err error) { + if path != "" { + if len(args) < 1 { + return nil, "", "", fmt.Errorf("resource id is required") + } + if file != "" { + value, err = readSetValue(file, "") + return args, path, value, err + } + if len(args) < 2 { + return nil, "", "", fmt.Errorf("value or --file is required for key path %s", path) + } + return args[:len(args)-1], path, args[len(args)-1], nil + } + if file != "" { + if len(args) < 2 { + return nil, "", "", fmt.Errorf("resource id and key path are required") + } + value, err = readSetValue(file, "") + return args[:len(args)-1], args[len(args)-1], value, err + } + if len(args) >= 1 && looksLikeKeyPath(args[len(args)-1]) { + return nil, "", "", fmt.Errorf("value or --file is required for key path %s", args[len(args)-1]) + } + if len(args) < 3 { + return nil, "", "", fmt.Errorf("resource id, key path, and value are required (or use --file)") + } + return args[:len(args)-2], args[len(args)-2], args[len(args)-1], nil +} + +func looksLikeKeyPath(value string) bool { + return keyPathPattern.MatchString(strings.TrimSpace(value)) } diff --git a/cmd/client/command/set/parse_test.go b/cmd/client/command/set/parse_test.go new file mode 100644 index 00000000..72054cda --- /dev/null +++ b/cmd/client/command/set/parse_test.go @@ -0,0 +1,68 @@ +package set + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSetArgsFile(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "script.js") + require.NoError(t, os.WriteFile(name, []byte("return 1;"), 0o600)) + + selectors, keyPath, value, err := parseSetArgs([]string{"mysensor.1.dht.temp", "formatter.onReceive"}, name, "") + require.NoError(t, err) + assert.Equal(t, []string{"mysensor.1.dht.temp"}, selectors) + assert.Equal(t, "formatter.onReceive", keyPath) + assert.Equal(t, "return 1;", value) +} + +func TestParseSetArgsPathFlagAndFile(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "onConfig.js") + require.NoError(t, os.WriteFile(name, []byte("var x = 1;"), 0o600)) + + selectors, keyPath, value, err := parseSetArgs([]string{"ota_stm32_ab"}, name, "data.onConfig") + require.NoError(t, err) + assert.Equal(t, []string{"ota_stm32_ab"}, selectors) + assert.Equal(t, "data.onConfig", keyPath) + assert.Equal(t, "var x = 1;", value) +} + +func TestParseSetArgsInlinePath(t *testing.T) { + selectors, keyPath, value, err := parseSetArgs([]string{"gw1", "description", "USB"}, "", "") + require.NoError(t, err) + assert.Equal(t, []string{"gw1"}, selectors) + assert.Equal(t, "description", keyPath) + assert.Equal(t, "USB", value) +} + +func TestParseSetArgsFieldNestedInline(t *testing.T) { + selectors, keyPath, value, err := parseSetArgs([]string{"mysensor.1.dht.temp", "formatter.onReceive", "return v;"}, "", "") + require.NoError(t, err) + assert.Equal(t, []string{"mysensor.1.dht.temp"}, selectors) + assert.Equal(t, "formatter.onReceive", keyPath) + assert.Equal(t, "return v;", value) +} + +func TestParseSetArgsMissing(t *testing.T) { + _, _, _, err := parseSetArgs([]string{"gw1"}, "", "") + require.Error(t, err) +} + +func TestParseSetArgsKeyPathWithoutValue(t *testing.T) { + _, _, _, err := parseSetArgs([]string{"gw1.1.1.V_CUSTOM", "formatter.onReceive"}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "value or --file is required") + assert.Contains(t, err.Error(), "formatter.onReceive") +} + +func TestParseSetArgsBareNameRequiresValue(t *testing.T) { + _, _, _, err := parseSetArgs([]string{"gw1.1.1.V_CUSTOM", "name"}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "resource id, key path, and value are required") +} diff --git a/cmd/client/command/set/set_cmd.go b/cmd/client/command/set/set_cmd.go index 2a765f43..246b2ab9 100644 --- a/cmd/client/command/set/set_cmd.go +++ b/cmd/client/command/set/set_cmd.go @@ -1,24 +1,72 @@ package set import ( - rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" "github.com/spf13/cobra" ) func init() { - setCmd.AddCommand(fieldGetCmd) + setCmd.AddCommand(newSetResourceCmd("gateway", []string{"gw", "gateways"}, "gateway")) + setCmd.AddCommand(newSetResourceCmd("node", []string{"nodes"}, "node")) + setCmd.AddCommand(newSetResourceCmd("source", []string{"sources"}, "source")) + setCmd.AddCommand(newSetResourceCmd("field", []string{"fields"}, "field")) + setCmd.AddCommand(newSetResourceCmd("firmware", []string{"firmwares", "fw"}, "firmware")) + setCmd.AddCommand(newSetResourceCmd("data-repository", []string{"data-repositories", "data-repo", "datarepository"}, "data-repository")) + setCmd.AddCommand(valueSetCmd) } -var fieldGetCmd = &cobra.Command{ - Use: "field", - Aliases: []string{"fields"}, - Short: "Sets the value to the fields resource", - PreRun: func(cmd *cobra.Command, args []string) { - rootCmd.UpdateStreams(cmd) - }, - Args: cobra.MinimumNArgs(2), - Run: func(cmd *cobra.Command, args []string) { - payload := args[len(args)-1] - executeSetCmd(QuickIDPrefixField, "", args[:len(args)-1], payload) - }, +func newSetResourceCmd(use string, aliases []string, kind string) *cobra.Command { + return &cobra.Command{ + Use: use + " [value]", + Aliases: aliases, + Short: "Set a nested field on " + use + " resource(s)", + Long: `Update a nested property on one or more resources. +The key path uses dots, for example formatter.onReceive or data.onConfig. + +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), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + selectors, keyPath, value, err := parseSetArgs(args, setFile, setPath) + if err != nil { + return err + } + return executeSetPath(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` + 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"` + } + if use == "gateway" { + 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` + } + if use == "source" { + 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` + } + 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` + } + return examples } diff --git a/cmd/client/command/set/value_cmd.go b/cmd/client/command/set/value_cmd.go new file mode 100644 index 00000000..8d38b4eb --- /dev/null +++ b/cmd/client/command/set/value_cmd.go @@ -0,0 +1,35 @@ +package set + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var valueSetCmd = &cobra.Command{ + Use: "value", + Short: "Set a live field value", + Long: `Set a live sensor/actuator value. This sends an action; it does not change stored resource metadata.`, +} + +func init() { + valueSetCmd.AddCommand(valueFieldCmd) +} + +var valueFieldCmd = &cobra.Command{ + 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), + 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) + }, +} diff --git a/cmd/client/command/upload/cmd.go b/cmd/client/command/upload/cmd.go new file mode 100644 index 00000000..08deb0c7 --- /dev/null +++ b/cmd/client/command/upload/cmd.go @@ -0,0 +1,126 @@ +package upload + +import ( + "fmt" + "io" + "time" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + "github.com/nleeper/goment" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.Cmd.AddCommand(uploadCmd) + uploadCmd.AddCommand(firmwareUploadCmd) +} + +var uploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload files to the server", + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, +} + +var firmwareUploadCmd = &cobra.Command{ + 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 +`, + Example: ` myc upload firmware stm32-app ./app.signed.bin + myc upload fw stm32-app ./app.bin`, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(2), + 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() + existing, err := client.FindFirmware(id) + if err != nil { + return fmt.Errorf("failed to look up firmware %s: %w", id, err) + } + if existing == nil { + return fmt.Errorf("firmware %s is not present", id) + } + if err := client.UploadFirmware(id, filename); err != nil { + return fmt.Errorf("failed to upload %s: %w", filename, err) + } + firmware, err := client.FindFirmware(id) + if err != nil { + return fmt.Errorf("uploaded, but failed to load firmware details: %w", err) + } + if firmware == nil { + return fmt.Errorf("uploaded, but firmware %s is not present", id) + } + printFirmwareUpload(rootCmd.IOStreams.Out, firmware) + return nil + }, +} + +func printFirmwareUpload(out io.Writer, firmware *firmwareTY.Firmware) { + file := firmware.File + rows := [][2]string{ + {"firmware", firmware.ID}, + {"name", file.Name}, + {"internal name", file.InternalName}, + {"size", formatFileSize(file.Size)}, + {"checksum", file.Checksum}, + {"modified", formatRelativeTime(file.ModifiedOn)}, + } + width := 0 + for _, row := range rows { + if len(row[0]) > width { + width = len(row[0]) + } + } + _, _ = fmt.Fprintln(out, "uploaded") + for _, row := range rows { + if row[1] == "" { + continue + } + _, _ = fmt.Fprintf(out, " %-*s %s\n", width, row[0], row[1]) + } +} + +func formatFileSize(size int) string { + if size <= 0 { + return "0 B" + } + if size < 1024 { + return fmt.Sprintf("%d B", size) + } + units := []string{"KiB", "MiB", "GiB", "TiB"} + value := float64(size) + unit := "B" + for _, next := range units { + if value < 1024 { + break + } + value /= 1024 + unit = next + } + return fmt.Sprintf("%.2f %s", value, unit) +} + +func formatRelativeTime(value time.Time) string { + if value.IsZero() { + return "" + } + g, err := goment.New(value) + if err != nil { + return value.Format(time.RFC3339) + } + return g.FromNow() +} diff --git a/cmd/client/command/upload/cmd_test.go b/cmd/client/command/upload/cmd_test.go new file mode 100644 index 00000000..cb3e09a1 --- /dev/null +++ b/cmd/client/command/upload/cmd_test.go @@ -0,0 +1,43 @@ +package upload + +import ( + "bytes" + "strings" + "testing" + "time" + + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + "github.com/stretchr/testify/assert" +) + +func TestFormatFileSize(t *testing.T) { + assert.Equal(t, "0 B", formatFileSize(0)) + assert.Equal(t, "512 B", formatFileSize(512)) + assert.Equal(t, "67.83 KiB", formatFileSize(69458)) + assert.Equal(t, "1.50 MiB", formatFileSize(1572864)) +} + +func TestPrintFirmwareUpload(t *testing.T) { + out := &bytes.Buffer{} + printFirmwareUpload(out, &firmwareTY.Firmware{ + ID: "stm32-app-slot-a", + File: firmwareTY.FileConfig{ + Name: "firmware.signed.bin", + InternalName: "stm32-app-slot-a.bin", + Checksum: "sha256:b1f3c09e4acbbf8ca40e6197bad871757c950cac4329b63aff8b171fdbdb3fe6", + Size: 69458, + ModifiedOn: time.Now().Add(-time.Minute), + }, + }) + text := out.String() + assert.True(t, strings.HasPrefix(text, "uploaded\n")) + assert.Contains(t, text, "firmware") + assert.Contains(t, text, "stm32-app-slot-a") + assert.Contains(t, text, "name") + assert.Contains(t, text, "firmware.signed.bin") + assert.Contains(t, text, "internal name") + assert.Contains(t, text, "stm32-app-slot-a.bin") + assert.Contains(t, text, "67.83 KiB") + assert.Contains(t, text, "sha256:b1f3c09e4acbbf8ca40e6197bad871757c950cac4329b63aff8b171fdbdb3fe6") + assert.Contains(t, text, "modified") +} diff --git a/cmd/client/main.go b/cmd/client/main.go index f01aa829..bc971105 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -4,12 +4,14 @@ import ( 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/cmd/client/command/apply" _ "github.com/mycontroller-org/server/v2/cmd/client/command/delete" _ "github.com/mycontroller-org/server/v2/cmd/client/command/disable" _ "github.com/mycontroller-org/server/v2/cmd/client/command/enable" _ "github.com/mycontroller-org/server/v2/cmd/client/command/get" _ "github.com/mycontroller-org/server/v2/cmd/client/command/reload" _ "github.com/mycontroller-org/server/v2/cmd/client/command/set" + _ "github.com/mycontroller-org/server/v2/cmd/client/command/upload" ) func main() { diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..e33593bd --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,692 @@ +# 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. + +--- + +## 1. Overview + +`myc` talks to a running MyController **server** over its HTTP API. It does not start the server. + +| Command | Purpose | +| --- | --- | +| `login` / `logout` | Store or clear server credentials | +| `version` | Print client and server version | +| `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 | +| `set` | Update a stored property, or set a live field value | +| `delete` | Delete resources by id | +| `enable` / `disable` | Enable or disable resources | +| `reload` | Reload a gateway or virtual assistant | + +### Build + +```bash +make client +# binary: builds/myc +``` + +Or: + +```bash +go build -trimpath -o builds/myc ./cmd/client +``` + +--- + +## 2. Configuration + +After a successful login, `myc` writes `$HOME/.mycontroller.yaml` (override with `--config`). + +Environment variables use the prefix `MYC_` (Viper automatic env). Example: `MYC_URL`. + +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 | +| `-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 + +```bash +# username and password +myc login http://localhost:8080 --username admin --password password + +# prompt for username and password +myc login http://localhost:8080 + +# prompt for password only +myc login http://localhost:8080 --username admin + +# service token +myc login http://localhost:8080 --token + +# TLS without certificate verification +myc login https://localhost:8443 --username admin --password password --insecure +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `-u`, `--username` | | Login username | +| `-p`, `--password` | | Login password | +| `-t`, `--token` | | Service token (skips username/password) | +| `--expires-in` | `720h` | Session lifetime | +| `--insecure` | `false` | Skip TLS certificate verification | + +```bash +myc logout +``` + +```bash +myc version +``` + +Prints client build information. If logged in, also queries the server version. + +--- + +## 4. Get + +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 +``` + +### Persistent flags + +| Flag | Default | Description | +| --- | --- | --- | +| `--limit` | `10` | Maximum rows | +| `--sort-by` | `id` | Sort key (header title or value path) | +| `--sort-order` | `asc` | `asc` or `desc` | +| `--filter` | | Repeatable `key=value` filter | + +### Filter operators + +The first matching operator in the filter string is used: + +| Syntax | Operator | +| --- | --- | +| `==` | equal | +| `!=` | not equal | +| `>=` | greater than or equal | +| `<=` | less than or equal | +| `>` | greater than | +| `<` | less than | +| `=` | regex (case insensitive) | + +The key is matched against the table header title (spaces ignored, case insensitive). If the header has a value path, that path is used (for example `gateway id` → `gatewayId`). + +### Resources + +| Command | Aliases | +| --- | --- | +| `get gateway` | `gw`, `gateways` | +| `get node` | `nodes` | +| `get source` | `sources` | +| `get field` | `fields` | +| `get firmware` | `firmwares`, `fw` | +| `get data-repository` | `data-repositories`, `data-repo` | +| `get virtual-device` | `virtual-devices`, `vd` | +| `get virtual-assistant` | `virtual-assistants`, `va` | +| `get task` | `tasks` | +| `get schedule` | `schedules` | +| `get handler` | `handlers` | +| `get forward-payload` | `forward-payloads` | +| `get backup` | `backups` | + +--- + +## 5. Apply + +`myc apply` creates, merges, or deletes **gateways**, **nodes**, **sources**, **fields**, **firmware**, and **data repositories** from a YAML or JSON file. + +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 +``` + +| Flag | Description | +| --- | --- | +| `-f`, `--filename` | YAML or JSON file. Repeat for multiple files. `-` reads stdin. Required. | +| `--dry-run` | Check the server and print the table without writing changes | +| `--replace` | If an `add` target already exists, delete it and recreate it with the **same id** | + +The command prints one table and exits `1` if any row failed. There is no extra summary line after the table. + +```text +RESOURCE ACTION STATUS +gateway: mysensor add ok +node: mysensor.1 add ok +source: mysensor.1.dht add replaced +field: mysensor.1.dht.temperature add failed: already exists +``` + +### 5.1 Action and status + +**ACTION** is the operation from the file (`add`, `merge`, `delete`), not the internal recreate step. + +| STATUS | Meaning | +| --- | --- | +| `ok` | add, merge, or delete succeeded | +| `replaced` | the resource already existed and was deleted then recreated with the same id | +| `failed: …` | the operation did not run, with a short reason | +| `not available` | delete target was not found; remaining resources still run | +| `dry-run` | `--dry-run` and the operation would have succeeded | + +`--replace` or `replace: true` in the file only affects **add**. It does not change ACTION to `replace`. + +### 5.2 File shapes + +The file may be: + +- a single object +- a YAML stream of documents separated by `---` +- a YAML or JSON array of objects +- an object with a shared header and an `items` list + +JSON is detected when the file starts with `{` or `[`. + +### 5.3 Single resource + +```yaml +kind: gateway +operation: add +id: mysensor +description: MySensors USB gateway +enabled: true +``` + +```yaml +kind: node +operation: add +gatewayId: mysensor +nodeId: "1" +name: Living Room +labels: + location: living-room +``` + +```yaml +kind: source +operation: merge +gatewayId: mysensor +nodeId: "1" +sourceId: dht +name: DHT Sensor +``` + +```yaml +kind: field +operation: delete +gatewayId: mysensor +nodeId: "1" +sourceId: dht +fieldId: temperature +``` + +`kind` aliases: `gateway` / `gw` / `gateways`, `node` / `nodes`, `source` / `sources`, `field` / `fields`. + +`operation` aliases: `add` / `create`, `update`, `delete` / `remove`. + +### 5.4 Items list + +`kind`, `operation`, and `replace` on the document apply to every item. Other keys on the document are defaults merged into each item (item keys win). + +```yaml +kind: field +operation: add +replace: true +gatewayId: mysensor +nodeId: "1" +sourceId: dht +items: + - fieldId: temperature + name: Temperature + metricType: gauge + unit: °C + - fieldId: humidity + name: Humidity + metricType: gauge + unit: "%" +``` + +If an item includes `fieldId`, it is applied as a **field** even when the document `kind` is `source` or `node`. + +```yaml +kind: source +operation: add +replace: true +items: + - gatewayId: mysensor + nodeId: "1" + sourceId: dht + fieldId: temperature + name: Temperature + metricType: gauge + unit: °C +``` + +That item is a field, not a source. + +### 5.5 Identity and required fields + +| Kind | Identity | Required for add/update | Required for delete | +| --- | --- | --- | --- | +| gateway | `id` | `id` | `id` | +| firmware | `id` | `id` | `id` | +| data-repository | `id` | `id` | `id` | +| node | `gatewayId` + `nodeId` | `gatewayId`, `nodeId` | `id` or `gatewayId`+`nodeId` | +| source | `gatewayId` + `nodeId` + `sourceId` | those three | `id` or those three | +| field | `gatewayId` + `nodeId` + `sourceId` + `fieldId` | those four | `id` or those four | + +Lookup uses `id` when it is set, otherwise the natural keys. + +Gateway, firmware, and data-repository HTTP APIs require an `id` on save; supply it in the file. For a new node or source without `id`, the client generates a UUID. A new field may omit `id`; the server assigns one. + +Apply of firmware writes **metadata only** (`id`, `description`, `labels`). The binary stays empty until `myc upload firmware`. Updating firmware metadata keeps the existing file. Replacing a firmware deletes the old file; upload again after replace. + +### 5.6 Operations + +**add** + +- Resource missing: create it. Status `ok`. +- Resource present: fail with `failed: already exists`, unless `--replace` or `replace: true`. +- With replace: delete the existing resource and create the new one using the **deleted resource’s id**, so references keep working. Status `replaced`. + +**merge** (`update` is accepted as an alias) + +- Resource present: deep-merge the file onto the live resource and save. The existing storage id is always kept. Status `ok`. +- Resource missing: `failed: not found`. + +Merge rules: + +- Keys not in the file stay as they are. +- Nested maps (`labels`, `others`, `provider`, `data`, and any other object) are merged key by key. +- Arrays of objects are merged by a key on each item, tried in this order: `id`, `key`, `fieldId`, `field`, `name`, `type`, `sourceId`. Matching items are deep-merged; new items are appended; items only on the server stay. +- Arrays of scalars (strings, numbers) are replaced by the file value. + +**delete** + +- Resource present: delete it. Status `ok`. +- Resource missing: `not available`. This is not a failure; later resources still run. + +### 5.7 Parent checks + +Add, merge, and replace require the parent to exist: + +| Resource | Parent | +| --- | --- | +| gateway | none | +| firmware | none | +| data-repository | none | +| node | gateway (`gatewayId`) | +| source | node (`gatewayId` + `nodeId`) | +| field | source (`gatewayId` + `nodeId` + `sourceId`) | + +A parent added (or replaced/merged) **earlier in the same apply** counts as present. A parent deleted earlier in the same apply counts as missing. If a parent write fails during apply, later children in the same file are not saved. + +```yaml +kind: gateway +operation: add +id: mysensor +enabled: true +--- +kind: node +operation: add +gatewayId: mysensor +nodeId: "1" +name: Living Room +``` + +If the gateway is missing and is not added earlier in the file: + +```text +RESOURCE ACTION STATUS +node: mysensor.1 add failed: parent gateway mysensor is not present +``` + +Delete does not require a parent. + +### 5.8 Resource fields + +Any field on the server type can be set in the file. Common ones: + +**Gateway** (`kind: gateway`) + +| Field | Description | +| --- | --- | +| `id` | Gateway id (required) | +| `description` | Text | +| `enabled` | `true` / `false` | +| `reconnectDelay` | Duration string, for example `15s` | +| `queueFailedMessage` | `true` / `false` | +| `provider` | Provider map (`type`, `protocol`, …) | +| `messageLogger` | Logger map | +| `labels` | String map | +| `others` | Free-form map | + +**Node** + +| Field | Description | +| --- | --- | +| `id` | Storage id (optional on add) | +| `gatewayId`, `nodeId` | Natural keys | +| `name` | Display name | +| `labels`, `others` | Maps | +| `state` | Status object | + +**Source** + +| Field | Description | +| --- | --- | +| `id` | Storage id (optional on add) | +| `gatewayId`, `nodeId`, `sourceId` | Natural keys | +| `name` | Display name | +| `labels`, `others` | Maps | + +**Firmware** (`kind: firmware`) + +| Field | Description | +| --- | --- | +| `id` | Firmware id (required) | +| `description` | Text | +| `labels` | String map (for example `platform`, `ms_flash_slot`) | + +Do not put a binary path in apply. Use `myc upload firmware`. + +**Data repository** (`kind: data-repository`) + +Aliases: `datarepository`, `data-repo`, `data-repositories`. + +| Field | Description | +| --- | --- | +| `id` | Repository id (required) | +| `description` | Text | +| `readOnly` | `true` / `false` | +| `labels` | String map | +| `data` | Free-form map (scripts and policy values) | + +**Field** + +| Field | Description | +| --- | --- | +| `id` | Storage id (optional on add) | +| `gatewayId`, `nodeId`, `sourceId`, `fieldId` | Natural keys | +| `name` | Display name | +| `metricType` | For example `gauge` | +| `unit` | For example `°C` | +| `formatter` | Payload formatter (`onReceive`) | +| `labels`, `others` | Maps | + +Numeric ids in YAML (for example `nodeId: 1`) are accepted and stored as strings. + +### 5.9 Full example + +```yaml +kind: gateway +operation: add +id: mysensor +description: MySensors USB gateway +enabled: true +--- +kind: node +operation: add +gatewayId: mysensor +nodeId: "1" +name: Living Room +labels: + location: living-room +--- +kind: source +operation: add +gatewayId: mysensor +nodeId: "1" +sourceId: dht +name: DHT Sensor +--- +kind: field +operation: add +gatewayId: mysensor +nodeId: "1" +sourceId: dht +fieldId: temperature +name: Temperature +metricType: gauge +unit: °C +--- +kind: field +operation: add +replace: true +gatewayId: mysensor +nodeId: "1" +sourceId: dht +items: + - fieldId: humidity + name: Humidity + metricType: gauge + unit: "%" +``` + +```yaml +kind: firmware +operation: add +id: stm32-app-slot-a +description: STM32 slot A image +labels: + ms_flash_slot: A +--- +kind: data-repository +operation: add +id: ota_stm32_ab +description: STM32 A/B OTA policy +data: + disabled: false +``` + +Verify first: + +```bash +myc apply -f resources.yaml --dry-run +``` + +Then apply. Use `--replace` when you want existing `add` targets recreated instead of failing. + +--- + +## 6. Upload firmware + +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 +``` + +| Argument | Description | +| --- | --- | +| `` | Firmware resource id | +| `` | Path to the binary on disk | + +On success the client prints the stored file details: + +```text +uploaded + firmware stm32-app-slot-a + name firmware.signed.bin + internal name stm32-app-slot-a.bin + size 67.83 KiB + checksum sha256:b1f3c09e4acbbf8ca40e6197bad871757c950cac4329b63aff8b171fdbdb3fe6 + modified a minute ago +``` + +The server updates `file.name`, `file.size`, `file.checksum` (sha256), and `file.modifiedOn`. + +If the firmware id is not present: + +```text +firmware stm32-app-slot-a is not present +``` + +--- + +## 7. Set + +`set ` updates a **stored property** on a resource (scripts, description, labels, and other fields). Live sensor/actuator values use a separate command: `set value field`. + +### Nested property (scripts and other text) + +```bash +myc set +myc set --file script.js +myc set --path --file script.js +``` + +| Kind | Aliases | Id | +| --- | --- | --- | +| `gateway` | `gw`, `gateways` | gateway id | +| `node` | `nodes` | storage id or `gatewayId.nodeId` | +| `source` | `sources` | storage id or `gatewayId.nodeId.sourceId` | +| `field` | `fields` | storage id or `gatewayId.nodeId.sourceId.fieldId` | +| `firmware` | `firmwares`, `fw` | firmware id | +| `data-repository` | `data-repo`, `data-repositories` | repository id | + +The key path uses dots and matches JSON field names: + +| Example | What it updates | +| --- | --- | +| `formatter.onReceive` | Field receive script | +| `data.onConfig` | Data-repository script | +| `data.onBlock` | Data-repository script | +| `description` | Description text | +| `labels.ms_flash_slot` | A single label | + +`--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 +``` + +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 +``` + +### Live field value + +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 +``` + +Do not use `myc set field` for this. `set field` always updates a stored key path (`formatter.onReceive`, `name`, `unit`, …). + +--- + +## 8. Delete, enable, disable, reload + +These commands take one or more **storage ids** (the `id` column from `get`, not the quick id), except as noted. + +### Delete + +```bash +myc delete gateway [...] +myc delete node +myc delete source +myc delete field +``` + +| Resource | Aliases | +| --- | --- | +| `gateway` | `gw`, `gateways` | +| `node` | `nodes` | +| `source` | `sources` | +| `field` | `fields` | +| `firmware` | `firmwares`, `fw` | +| `data-repository` | `data-repositories`, `data-repo` | +| `virtual-device` | `virtual-devices`, `vd` | +| `virtual-assistant` | `virtual-assistants`, `va` | +| `task` | `tasks` | +| `schedule` | `schedules` | +| `handler` | `handlers` | +| `forward-payload` | `forward-payloads` | +| `backup` | `backups` | + +### Enable / disable + +```bash +myc enable gateway +myc disable task +``` + +Supported: `gateway`, `virtual-device`, `virtual-assistant`, `task`, `schedule`, `handler` (same aliases as `get`). + +### Reload + +```bash +myc reload gateway +myc reload virtual-assistant +``` + +Supported: `gateway`, `virtual-assistant`. + +--- + +## 9. Quick ids + +Several commands and the UI refer to resources by **quick id**: + +| Kind | Format | Example | +| --- | --- | --- | +| gateway | `{gatewayId}` | `mysensor` | +| node | `{gatewayId}.{nodeId}` | `mysensor.1` | +| source | `{gatewayId}.{nodeId}.{sourceId}` | `mysensor.1.dht` | +| field | `{gatewayId}.{nodeId}.{sourceId}.{fieldId}` | `mysensor.1.dht.temperature` | + +`get` shows a `quick id` column in `-o wide`. `apply` prints the same shape after the kind in the RESOURCE column (`node: mysensor.1`). + +`delete`, `enable`, `disable`, and `reload` use the storage **id** (UUID or configured gateway id), not the dotted quick id. + +--- + +## 10. Exit status + +| Situation | Exit code | +| --- | --- | +| All apply rows succeeded, or delete-not-available only | `0` | +| Any apply row `failed` | `1` | +| Missing file, parse error, or other command error | `1` | diff --git a/pkg/api/field/api.go b/pkg/api/field/api.go index 5b8ea32f..7299520d 100644 --- a/pkg/api/field/api.go +++ b/pkg/api/field/api.go @@ -67,11 +67,15 @@ func (f *FieldAPI) Save(field *fieldTY.Field, retainValue bool) error { if retainValue && eventType != eventTY.TypeCreated { fieldOrg, err := f.GetByID(field.ID) - if err != nil { + if err == nil { + field.Current = fieldOrg.Current + field.Previous = fieldOrg.Previous + } else if err != storageTY.ErrNoDocuments { return err + } else { + // supplied id with no stored document is a create (POST after delete) + eventType = eventTY.TypeCreated } - field.Current = fieldOrg.Current - field.Previous = fieldOrg.Previous } err := f.storage.Upsert(types.EntityField, field, filters) if err != nil { diff --git a/pkg/api/field/api_test.go b/pkg/api/field/api_test.go new file mode 100644 index 00000000..215e0388 --- /dev/null +++ b/pkg/api/field/api_test.go @@ -0,0 +1,68 @@ +package field + +import ( + "context" + "testing" + + fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" + busTY "github.com/mycontroller-org/server/v2/plugin/bus/types" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type stubStorage struct { + storageTY.Plugin + getErr error + upserts int +} + +func (s *stubStorage) FindOne(entityName string, out interface{}, filter []storageTY.Filter) error { + return s.getErr +} + +func (s *stubStorage) Upsert(entityName string, data interface{}, filter []storageTY.Filter) error { + s.upserts++ + return nil +} + +type stubBus struct{} + +func (stubBus) Name() string { return "stub" } +func (stubBus) Close() error { return nil } +func (stubBus) Publish(string, interface{}) error { return nil } +func (stubBus) Subscribe(string, busTY.CallBackFunc) (int64, error) { + return 0, nil +} +func (stubBus) Unsubscribe(string, int64) error { return nil } +func (stubBus) QueueSubscribe(string, string, busTY.CallBackFunc) (int64, error) { + return 0, nil +} +func (stubBus) QueueUnsubscribe(string, string, int64) error { return nil } +func (stubBus) UnsubscribeAll(string) error { return nil } +func (stubBus) PausePublish() {} +func (stubBus) ResumePublish() {} +func (stubBus) TopicPrefix() string { return "" } + +func TestSaveRetainValueMissingDocumentCreates(t *testing.T) { + storage := &stubStorage{getErr: storageTY.ErrNoDocuments} + api := New(context.Background(), zap.NewNop(), storage, stubBus{}) + err := api.Save(&fieldTY.Field{ + ID: "missing-id", + GatewayID: "gw1", + NodeID: "n1", + SourceID: "s1", + FieldID: "temp", + Name: "Temperature", + }, true) + require.NoError(t, err) + require.Equal(t, 1, storage.upserts) +} + +func TestSaveRetainValueOtherGetError(t *testing.T) { + storage := &stubStorage{getErr: context.DeadlineExceeded} + api := New(context.Background(), zap.NewNop(), storage, stubBus{}) + err := api.Save(&fieldTY.Field{ID: "id-1"}, true) + require.Error(t, err) + require.Equal(t, 0, storage.upserts) +} diff --git a/pkg/utils/http_client_json/client.go b/pkg/utils/http_client_json/client.go index ec5bc1e1..6b804a07 100644 --- a/pkg/utils/http_client_json/client.go +++ b/pkg/utils/http_client_json/client.go @@ -5,8 +5,11 @@ import ( "crypto/tls" "fmt" "io" + "mime/multipart" "net/http" "net/http/cookiejar" + "os" + "path/filepath" "time" json "github.com/mycontroller-org/server/v2/pkg/json" @@ -140,6 +143,66 @@ func (c *Client) ExecuteJson(url, method string, headers map[string]string, quer return respCfg, nil } +// ExecuteMultipart posts a file as multipart form field "file". +func (c *Client) ExecuteMultipart(url, method string, headers map[string]string, fieldName, filename string, responseCode int) (*ResponseConfig, error) { + if fieldName == "" { + fieldName = "file" + } + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile(fieldName, filepath.Base(filename)) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, file); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest(method, url, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if responseCode > 0 && resp.StatusCode != responseCode { + return nil, fmt.Errorf("failed with status code. [status: %v, statusCode: %v, body: %s]", resp.Status, resp.StatusCode, string(respBodyBytes)) + } + + respCfg := &ResponseConfig{ + StatusCode: resp.StatusCode, + URL: url, + Method: method, + Body: respBodyBytes, + Headers: make(map[string]string), + } + for k := range resp.Header { + respCfg.Headers[k] = resp.Header.Get(k) + } + return respCfg, nil +} + // Request implementation func (c *Client) Execute(url, method string, headers map[string]string, queryParams map[string]interface{}, body string, responseCode int) (*ResponseConfig, error) { From 9a0da228b6b2be6b9378faf8b3f7b295db8755a8 Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Fri, 11 Sep 2026 17:04:54 +0530 Subject: [PATCH 2/2] cli: add node and gateway actions with quick ids Wire myc reboot node and myc action for reboot, reset, firmware-update, heartbeat, refresh-node-info, and gateway discover-nodes. Resolve node quick ids (gw.node), look up gateways before acting, and accept multiple space-separated ids. --- cmd/client/api/action.go | 23 ++++- cmd/client/api/api.go | 5 +- cmd/client/api/set_path.go | 56 ++++++++++- cmd/client/command/action/cmd.go | 122 ++++++++++++++++++++++++ cmd/client/command/action/cmd_test.go | 42 ++++++++ cmd/client/command/reboot/cmd.go | 2 +- cmd/client/command/reboot/reboot_cmd.go | 23 +++-- cmd/client/command/reload/cmd.go | 4 +- cmd/client/command/reload/reload_cmd.go | 30 ++++-- cmd/client/main.go | 2 + docs/cli.md | 45 ++++++++- pkg/utils/http_client_json/client.go | 9 +- 12 files changed, 330 insertions(+), 33 deletions(-) create mode 100644 cmd/client/command/action/cmd.go create mode 100644 cmd/client/command/action/cmd_test.go diff --git a/cmd/client/api/action.go b/cmd/client/api/action.go index 71ed7f56..76c70e1a 100644 --- a/cmd/client/api/action.go +++ b/cmd/client/api/action.go @@ -1,13 +1,32 @@ package api import ( + "fmt" "net/http" webHandlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" ) -func (c *Client) ExecuteNodeAction(action string, nodeIDs []string) error { - _, err := c.executeJson(API_ACTION_NODE, http.MethodGet, nil, nil, nodeIDs, http.StatusOK) +func (c *Client) ExecuteNodeAction(action string, ids []string) error { + return c.executeIDAction(API_ACTION_NODE, action, ids) +} + +func (c *Client) ExecuteGatewayAction(action string, ids []string) error { + return c.executeIDAction(API_ACTION_GATEWAY, action, ids) +} + +func (c *Client) executeIDAction(api, action string, ids []string) error { + if action == "" { + return fmt.Errorf("action is required") + } + if len(ids) == 0 { + return fmt.Errorf("at least one id is required") + } + query := map[string]interface{}{ + "action": action, + "id": ids, + } + _, err := c.executeJson(api, http.MethodGet, nil, query, nil, http.StatusOK) return err } diff --git a/cmd/client/api/api.go b/cmd/client/api/api.go index ae765f13..7ebcc6be 100644 --- a/cmd/client/api/api.go +++ b/cmd/client/api/api.go @@ -14,8 +14,9 @@ const ( API_NODE_LIST = "/api/node" API_NODE_DELETE = "/api/node" - API_ACTION = "/api/action" - API_ACTION_NODE = "/api/action/node" + API_ACTION = "/api/action" + API_ACTION_NODE = "/api/action/node" + API_ACTION_GATEWAY = "/api/action/gateway" API_FIELD_LIST = "/api/field" API_FIELD_DELETE = "/api/field" diff --git a/cmd/client/api/set_path.go b/cmd/client/api/set_path.go index d3cbc534..35f9051b 100644 --- a/cmd/client/api/set_path.go +++ b/cmd/client/api/set_path.go @@ -107,12 +107,66 @@ func (c *Client) SetResourcePath(kind, selector, keyPath, value string, rawText } } +func (c *Client) ResolveGatewayIDs(selectors []string, requireEnabled bool) ([]string, error) { + ids := make([]string, 0, len(selectors)) + missing := make([]string, 0) + disabled := make([]string, 0) + for _, selector := range selectors { + selector = strings.TrimPrefix(selector, "gateway:") + item, err := c.FindGateway(selector) + if err != nil { + return ids, err + } + if item == nil { + missing = append(missing, selector) + continue + } + if requireEnabled && !item.Enabled { + disabled = append(disabled, selector) + continue + } + ids = append(ids, item.ID) + } + parts := make([]string, 0, 2) + if len(missing) > 0 { + parts = append(parts, "gateway(s) not present: "+strings.Join(missing, ", ")) + } + if len(disabled) > 0 { + parts = append(parts, "gateway(s) disabled: "+strings.Join(disabled, ", ")) + } + if len(parts) > 0 { + return ids, fmt.Errorf("%s", strings.Join(parts, "; ")) + } + return ids, nil +} + +func (c *Client) ResolveNodeIDs(selectors []string) ([]string, error) { + ids := make([]string, 0, len(selectors)) + missing := make([]string, 0) + for _, selector := range selectors { + item, err := c.findNodeSelector(selector) + if err != nil { + return ids, err + } + if item == nil { + missing = append(missing, selector) + continue + } + ids = append(ids, item.ID) + } + if len(missing) > 0 { + return ids, fmt.Errorf("node(s) not present: %s", strings.Join(missing, ", ")) + } + return ids, nil +} + func (c *Client) findNodeSelector(selector string) (*nodeTY.Node, error) { + selector = strings.TrimPrefix(selector, "node:") item, err := c.FindNode(selector, "", "") if err != nil || item != nil { return item, err } - parts := strings.Split(selector, ".") + parts := strings.SplitN(selector, ".", 2) if len(parts) == 2 { return c.FindNode("", parts[0], parts[1]) } diff --git a/cmd/client/command/action/cmd.go b/cmd/client/command/action/cmd.go new file mode 100644 index 00000000..d4d1f508 --- /dev/null +++ b/cmd/client/command/action/cmd.go @@ -0,0 +1,122 @@ +package action + +import ( + "fmt" + "strings" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + gatewayTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.Cmd.AddCommand(actionCmd) + actionCmd.AddCommand(nodeActionCmd) + actionCmd.AddCommand(gatewayActionCmd) +} + +var actionCmd = &cobra.Command{ + Use: "action", + Short: "Send an action to a node or gateway", + Long: `Send a server action to a node or gateway. + +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. +`, + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, +} + +var nodeActionCmd = &cobra.Command{ + 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), + 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]) + if err != nil { + return err + } + client := rootCmd.GetClient() + ids, resolveErr := client.ResolveNodeIDs(args[1:]) + if len(ids) > 0 { + if err := client.ExecuteNodeAction(action, ids); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "sent %s to %d node(s)\n", action, len(ids)) + } + return resolveErr + }, +} + +var gatewayActionCmd = &cobra.Command{ + 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), + 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]) + if err != nil { + return err + } + client := rootCmd.GetClient() + ids, resolveErr := client.ResolveGatewayIDs(args[1:], true) + if len(ids) > 0 { + if err := client.ExecuteGatewayAction(action, ids); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "sent %s to %d gateway(s)\n", action, len(ids)) + } + return resolveErr + }, +} + +func normalizeNodeAction(action string) (string, error) { + switch strings.ToLower(strings.ReplaceAll(action, "_", "-")) { + case "reboot": + return nodeTY.ActionReboot, nil + case "reset": + return nodeTY.ActionReset, nil + case "firmware-update": + return nodeTY.ActionFirmwareUpdate, nil + case "heartbeat", "heartbeat-request": + return nodeTY.ActionHeartbeatRequest, nil + case "refresh-node-info", "refresh": + return nodeTY.ActionRefreshNodeInfo, nil + default: + return "", fmt.Errorf("unsupported node action %q (supported: reboot, reset, firmware-update, heartbeat, refresh-node-info)", action) + } +} + +func normalizeGatewayAction(action string) (string, error) { + switch strings.ToLower(strings.ReplaceAll(action, "_", "-")) { + case "discover-nodes", "discover": + return gatewayTY.ActionDiscoverNodes, nil + default: + return "", fmt.Errorf("unsupported gateway action %q (supported: discover-nodes)", action) + } +} diff --git a/cmd/client/command/action/cmd_test.go b/cmd/client/command/action/cmd_test.go new file mode 100644 index 00000000..836c6eef --- /dev/null +++ b/cmd/client/command/action/cmd_test.go @@ -0,0 +1,42 @@ +package action + +import ( + "testing" + + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + gatewayTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeNodeAction(t *testing.T) { + cases := map[string]string{ + "reboot": nodeTY.ActionReboot, + "reset": nodeTY.ActionReset, + "firmware-update": nodeTY.ActionFirmwareUpdate, + "firmware_update": nodeTY.ActionFirmwareUpdate, + "heartbeat": nodeTY.ActionHeartbeatRequest, + "heartbeat-request": nodeTY.ActionHeartbeatRequest, + "refresh": nodeTY.ActionRefreshNodeInfo, + "refresh-node-info": nodeTY.ActionRefreshNodeInfo, + "refresh_node_info": nodeTY.ActionRefreshNodeInfo, + } + for in, want := range cases { + got, err := normalizeNodeAction(in) + require.NoError(t, err, in) + assert.Equal(t, want, got, in) + } + _, err := normalizeNodeAction("sleep") + require.Error(t, err) +} + +func TestNormalizeGatewayAction(t *testing.T) { + got, err := normalizeGatewayAction("discover") + require.NoError(t, err) + assert.Equal(t, gatewayTY.ActionDiscoverNodes, got) + got, err = normalizeGatewayAction("discover_nodes") + require.NoError(t, err) + assert.Equal(t, gatewayTY.ActionDiscoverNodes, got) + _, err = normalizeGatewayAction("reboot") + require.Error(t, err) +} diff --git a/cmd/client/command/reboot/cmd.go b/cmd/client/command/reboot/cmd.go index 77565e42..c43dd24f 100644 --- a/cmd/client/command/reboot/cmd.go +++ b/cmd/client/command/reboot/cmd.go @@ -12,7 +12,7 @@ func init() { var rebootCmd = &cobra.Command{ Use: "reboot", - Short: "Reboots the requested resources", + Short: "Reboot nodes", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, diff --git a/cmd/client/command/reboot/reboot_cmd.go b/cmd/client/command/reboot/reboot_cmd.go index ed8a15c5..0bd47270 100644 --- a/cmd/client/command/reboot/reboot_cmd.go +++ b/cmd/client/command/reboot/reboot_cmd.go @@ -13,20 +13,25 @@ func init() { } var nodeRebootCmd = &cobra.Command{ - Use: "node", + Use: "node [...]", Aliases: []string{"nodes"}, - Short: "Reboots the given nodes", + Short: "Reboot one or more nodes", + Example: ` myc reboot node mysensor.1 mysensor.2`, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { + Args: cobra.MinimumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { client := rootCmd.GetClient() - err := client.ExecuteNodeAction(nodeTY.ActionReboot, args) - if err != nil { - _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) - return + ids, resolveErr := client.ResolveNodeIDs(args) + if len(ids) > 0 { + if err := client.ExecuteNodeAction(nodeTY.ActionReboot, ids); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "sent reboot to %d node(s)\n", len(ids)) } - _, _ = fmt.Fprintln(rootCmd.IOStreams.Out, "Nodes reboot command supplied") + return resolveErr }, } diff --git a/cmd/client/command/reload/cmd.go b/cmd/client/command/reload/cmd.go index 9462487b..0071c0eb 100644 --- a/cmd/client/command/reload/cmd.go +++ b/cmd/client/command/reload/cmd.go @@ -20,10 +20,10 @@ var reloadCmd = &cobra.Command{ }, } -func printStatus(err error) { +func printStatus(err error, count int, kind string) { if err != nil { _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) return } - _, _ = fmt.Fprintln(rootCmd.IOStreams.Out, "Reloaded successfully") + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "reloaded %d %s(s)\n", count, kind) } diff --git a/cmd/client/command/reload/reload_cmd.go b/cmd/client/command/reload/reload_cmd.go index b6f4d1c1..26b0f032 100644 --- a/cmd/client/command/reload/reload_cmd.go +++ b/cmd/client/command/reload/reload_cmd.go @@ -1,8 +1,9 @@ package reload import ( - rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + "fmt" + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" "github.com/spf13/cobra" ) @@ -12,31 +13,40 @@ func init() { } var gwReloadCmd = &cobra.Command{ - Use: "gateway", + Use: "gateway [...]", Aliases: []string{"gw", "gateways"}, - Short: "Reloads the given gateways", + Short: "Reload one or more gateways", + Example: ` myc reload gateway mysensor gw2`, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { client := rootCmd.GetClient() - err := client.ReloadGateway(args...) - printStatus(err) + ids, resolveErr := client.ResolveGatewayIDs(args, false) + if len(ids) > 0 { + err := client.ReloadGateway(ids...) + printStatus(err, len(ids), "gateway") + if err != nil { + return + } + } + if resolveErr != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", resolveErr) + } }, } var virtualAssistantReloadCmd = &cobra.Command{ - Use: "virtual-assistant", + Use: "virtual-assistant [...]", Aliases: []string{"virtual-assistants", "va"}, - Short: "Reloads the given virtual assistants", + Short: "Reload one or more virtual assistants", PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := rootCmd.GetClient() - err := client.ReloadVirtualAssistant(args...) - printStatus(err) + err := rootCmd.GetClient().ReloadVirtualAssistant(args...) + printStatus(err, len(args), "virtual assistant") }, } diff --git a/cmd/client/main.go b/cmd/client/main.go index bc971105..36be5f52 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -4,11 +4,13 @@ import ( 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/cmd/client/command/action" _ "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" _ "github.com/mycontroller-org/server/v2/cmd/client/command/enable" _ "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/set" _ "github.com/mycontroller-org/server/v2/cmd/client/command/upload" diff --git a/docs/cli.md b/docs/cli.md index e33593bd..7e4fb1f0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -19,6 +19,8 @@ This document describes the **MyController command-line client**: how to build i | `delete` | Delete resources by id | | `enable` / `disable` | Enable or disable resources | | `reload` | Reload a gateway or virtual assistant | +| `reboot` | Reboot a node | +| `action` | Send a node or gateway action (reboot, reset, discover-nodes, …) | ### Build @@ -617,9 +619,9 @@ Do not use `myc set field` for this. `set field` always updates a stored key pat --- -## 8. Delete, enable, disable, reload +## 8. Delete, enable, disable, reload, reboot, action -These commands take one or more **storage ids** (the `id` column from `get`, not the quick id), except as noted. +`delete`, `enable`, `disable`, and `reload` take **storage ids** (the `id` column from `get`). Node `reboot` and `action node` take **quick ids** (`gatewayId.nodeId`). ### Delete @@ -658,12 +660,45 @@ Supported: `gateway`, `virtual-device`, `virtual-assistant`, `task`, `schedule`, ### Reload ```bash -myc reload gateway -myc reload virtual-assistant +myc reload gateway mysensor gw2 +myc reload virtual-assistant [...] ``` Supported: `gateway`, `virtual-assistant`. +### Reboot + +```bash +myc reboot node mysensor.1 mysensor.2 +``` + +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 [...] +``` + +| Target | Actions | +| --- | --- | +| `node` | `reboot`, `reset`, `firmware-update`, `heartbeat`, `refresh-node-info` | +| `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 +``` + +To reload gateways, use `myc reload gateway [...]`. There is no gateway restart or reboot action. + --- ## 9. Quick ids @@ -679,7 +714,7 @@ Several commands and the UI refer to resources by **quick id**: `get` shows a `quick id` column in `-o wide`. `apply` prints the same shape after the kind in the RESOURCE column (`node: mysensor.1`). -`delete`, `enable`, `disable`, and `reload` use the storage **id** (UUID or configured gateway id), not the dotted quick id. +`delete`, `enable`, `disable`, and `reload` use the storage **id** (UUID or configured gateway id). `reboot node` and `action node` use the node quick id (`gatewayId.nodeId`). --- diff --git a/pkg/utils/http_client_json/client.go b/pkg/utils/http_client_json/client.go index 6b804a07..dc3b2b9d 100644 --- a/pkg/utils/http_client_json/client.go +++ b/pkg/utils/http_client_json/client.go @@ -108,7 +108,14 @@ func (c *Client) ExecuteJson(url, method string, headers map[string]string, quer if queryParams != nil { q := req.URL.Query() for k, v := range queryParams { - q.Add(k, fmt.Sprintf("%v", v)) + switch typed := v.(type) { + case []string: + for _, item := range typed { + q.Add(k, item) + } + default: + q.Add(k, fmt.Sprintf("%v", v)) + } } req.URL.RawQuery = q.Encode() }