diff --git a/client/console-inspected/parse.go b/client/console-inspected/parse.go new file mode 100644 index 00000000..ccdad543 --- /dev/null +++ b/client/console-inspected/parse.go @@ -0,0 +1,62 @@ +package consoleinspected + +import ( + "fmt" + "strings" +) + +// Parse parses definition of inspected console item and extracts items using splitStrategy. +// +// It returns console item struct with its subcommands, commands, arguments, etc. +func Parse(input string, splitStrategy ItemsDefinitionSplitStrategy) (ConsoleItem, error) { + chunks, err := splitStrategy.Split(input) + if err != nil { + return ConsoleItem{}, err + } + + var result ConsoleItem + result.Self = Item{} + + for _, v := range chunks { + item, err := parseItem(v) + if err != nil { + return ConsoleItem{}, err + } + if item.Type == TypeSelf { + result.Self = item + continue + } + switch t := item.NodeType; t { + case NodeTypeDir: + result.Subcommands = append(result.Subcommands, item.Name) + case NodeTypeArg: + result.Arguments = append(result.Arguments, item) + case NodeTypeCommand: + result.Commands = append(result.Commands, item.Name) + default: + return ConsoleItem{}, fmt.Errorf("unknown node type %q", t) + } + } + + return result, nil +} + +func parseItem(input string) (Item, error) { + result := Item{} + for _, v := range strings.Split(input, ";") { + if strings.TrimSpace(v) == "" { + continue + } + if strings.HasPrefix(v, "name=") { + result.Name = strings.TrimPrefix(v, "name=") + } + if strings.HasPrefix(v, "node-type=") { + result.NodeType = NodeType(strings.TrimPrefix(v, "node-type=")) + } + if strings.HasPrefix(v, "type=") { + result.Type = Type(strings.TrimPrefix(v, "type=")) + } + } + + return result, nil +} diff --git a/client/console-inspected/parse_test.go b/client/console-inspected/parse_test.go new file mode 100644 index 00000000..626b06cf --- /dev/null +++ b/client/console-inspected/parse_test.go @@ -0,0 +1,65 @@ +package consoleinspected + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + testCases := []struct { + name string + input string + expected ConsoleItem + expectedError bool + }{ + { + name: "simple command", + input: "name=add;node-type=cmd;type=self;name=comment;node-type=arg;type=child;name=copy-from;node-type=arg;type=child;", + expected: ConsoleItem{ + Self: Item{ + Name: "add", + NodeType: NodeTypeCommand, + Type: TypeSelf, + }, + Arguments: []Item{ + {Name: "comment", NodeType: NodeTypeArg, Type: TypeChild}, + {Name: "copy-from", NodeType: NodeTypeArg, Type: TypeChild}, + }, + }, + }, + { + name: "command with subcommands", + input: "name=list;node-type=dir;type=self;name=add;node-type=cmd;type=child;name=comment;node-type=cmd;type=child;name=edit;node-type=cmd;type=child;name=export;node-type=cmd;type=child;name=find;node-type=cmd;type=child;name=get;node-type=cmd;type=child;name=member;node-type=dir;type=child;name=print;node-type=cmd;type=child;name=remove;node-type=cmd;type=child;name=reset;node-type=cmd;type=child;name=set;node-type=cmd;type=child", + expected: ConsoleItem{ + Self: Item{ + Name: "list", + NodeType: NodeTypeDir, + Type: TypeSelf, + }, + Commands: []string{ + "add", + "comment", + "edit", + "export", + "find", + "get", + "print", + "remove", + "reset", + "set", + }, + Subcommands: []string{"member"}, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + item, err := Parse(tc.input, DefaultSplitStrategy) + if !assert.Equal(t, !tc.expectedError, err == nil) || tc.expectedError { + return + } + assert.Equal(t, tc.expected, item) + }) + } +} diff --git a/client/console-inspected/split_strategy.go b/client/console-inspected/split_strategy.go new file mode 100644 index 00000000..5534cd6c --- /dev/null +++ b/client/console-inspected/split_strategy.go @@ -0,0 +1,38 @@ +package consoleinspected + +import "strings" + +var DefaultSplitStrategy = splitStrategyFunc(orderedSplit) + +type splitStrategyFunc func(string) ([]string, error) + +func (f splitStrategyFunc) Split(in string) ([]string, error) { + return f(in) +} + +// orderedSplit splits items definition using order of fields. +// +// Each 'name=' key starts a new item definition. +func orderedSplit(in string) ([]string, error) { + result := []string{} + + buf := strings.Builder{} + for _, v := range strings.Split(in, ";") { + if strings.TrimSpace(v) == "" { + continue + } + if strings.HasPrefix(v, "name=") { + if buf.Len() > 0 { + result = append(result, buf.String()) + } + buf.Reset() + } + buf.WriteString(v) + buf.WriteString(";") + } + if buf.Len() > 0 { + result = append(result, buf.String()) + } + + return result, nil +} diff --git a/client/console-inspected/types.go b/client/console-inspected/types.go new file mode 100644 index 00000000..ba15f076 --- /dev/null +++ b/client/console-inspected/types.go @@ -0,0 +1,58 @@ +package consoleinspected + +const ( + // NodeTypeDir represents console menu level. + NodeTypeDir NodeType = "dir" + + // NodeTypeCommand represents console command that can be called. + NodeTypeCommand NodeType = "cmd" + + // NodeTypeArg represents console item that is argument to a command. + NodeTypeArg NodeType = "arg" + + // TypeSelf is console item type for currently inspected item. + TypeSelf Type = "self" + + // TypeChild is console item type of all items within inspected container. + TypeChild Type = "child" +) + +type ( + // NodeType is dedicated type that holds values of "node-type" field of console item. + NodeType string + + // Type is dedicated type that holds values of "type" field of console item. + Type string + + // Item represents inspected console items. + Item struct { + NodeType NodeType `mikrotik:"node-type"` + Type Type `mikrotik:"type"` + Name string `mikrotik:"name"` + } + + // ConsoleItem represents inspected console item with extracted commands, arguments, etc. + ConsoleItem struct { + // Self holds information about current console item. + Self Item + + // Commands holds a list of commands available for this menu level. + // Valid only for ConsoleItem of type NodeTypeDir. + Commands []string + + // Subcommands holds a list of commands for the nested menu level. + // Valid only for ConsoleItem of type NodeTypeDir. + Subcommands []string + + // Arguments holds a list of argument items for a command. + // Valid only for ConsoleItem of type NodeItemCommand. + Arguments []Item + } +) + +type ( + ItemsDefinitionSplitStrategy interface { + // Split splits set of items definition represented by a single string into chunks of separate item definitions. + Split(string) ([]string, error) + } +) diff --git a/client/console_inspect.go b/client/console_inspect.go new file mode 100644 index 00000000..d0063edf --- /dev/null +++ b/client/console_inspect.go @@ -0,0 +1,42 @@ +package client + +import ( + "strings" + + consoleinspected "github.com/ddelnano/terraform-provider-mikrotik/client/console-inspected" +) + +func (c Mikrotik) InspectConsoleCommand(command string) (consoleinspected.ConsoleItem, error) { + client, err := c.getMikrotikClient() + if err != nil { + return consoleinspected.ConsoleItem{}, err + } + normalizedCommand := strings.ReplaceAll(command[1:], "/", ",") + cmd := []string{"/console/inspect", "as-value", "=path=" + normalizedCommand, "=request=child"} + reply, err := client.RunArgs(cmd) + if err != nil { + return consoleinspected.ConsoleItem{}, err + } + var items []consoleinspected.Item + var result consoleinspected.ConsoleItem + if err := Unmarshal(*reply, &items); err != nil { + return consoleinspected.ConsoleItem{}, err + } + + for _, v := range items { + if v.Type == consoleinspected.TypeSelf { + result.Self = v + continue + } + switch v.NodeType { + case consoleinspected.NodeTypeArg: + result.Arguments = append(result.Arguments, v) + case consoleinspected.NodeTypeCommand: + result.Commands = append(result.Commands, v.Name) + case consoleinspected.NodeTypeDir: + result.Subcommands = append(result.Subcommands, v.Name) + } + } + + return result, nil +} diff --git a/cmd/mikrotik-codegen/internal/codegen/README.md b/cmd/mikrotik-codegen/internal/codegen/README.md index 6055efa4..f1d719e2 100644 --- a/cmd/mikrotik-codegen/internal/codegen/README.md +++ b/cmd/mikrotik-codegen/internal/codegen/README.md @@ -14,6 +14,17 @@ where `commandBase` - base path to craft commands for CRUD operations. +It is also possible to pre-fill list of fields using either `-inspect-definition-file` argument +```sh +$ go run ./cmd/mikrotik-codegen mikrotik -name BridgeVlan -commandBase "/interface/bridge/vlan" -inspect-definition-file ./inspect_vlan.txt +``` + +or `-query-definition` flag (requires valid credentials in evironment) +```sh +$ go run ./cmd/mikrotik-codegen mikrotik -name BridgeVlan -commandBase "/interface/bridge/vlan" -query-definition +``` +For details, see [Experimental](#experimental) section. + ## Terraform resource Just add a `codegen` tag key to struct fields: ```go @@ -47,3 +58,92 @@ $ go run ./cmd/mikrotik-codegen terraform -src client/resource.go -struct Mikrot |computed|Mark field as `computed` in resource schema| |elemType|Explicitly set element type for `List` or `Set` attributes. Usage `elemType=int`| |omit|Skip this field from code generation process| + + +## Experimental + +This section contains documentation for experimental and non-stable features. + +### Generate Mikrotik resource using /console/inspect definition + +Modern RouterOS versions (>7.x) provide new `/console/inspect` command to query hierarchy or syntax of particular command. + +For example, `/console/inspect path=interface,list request=child` prints `parent-child` relationship of the command: +``` +Columns: TYPE, NAME, NODE-TYPE +TYPE NAME NODE-TYPE +self list dir +child add cmd +child comment cmd +child edit cmd +child export cmd +child find cmd +child get cmd +child member dir +child print cmd +child remove cmd +child reset cmd +child set cmd +``` + +while `/console/inspect path=interface,list request=syntax` gives another set of attributes: +``` +Columns: TYPE, SYMBOL, SYMBOL-TYPE, NESTED, NONORM, TEXT +TYPE SYMBOL SYMBOL-TYPE NESTED NONORM TEXT +syntax collection 0 yes +syntax .. explanation 1 no go up to interface +syntax add explanation 1 no Create a new item +syntax comment explanation 1 no Set comment for items +syntax edit explanation 1 no +syntax export explanation 1 no Print or save an export script that can be used to restore configuration +syntax find explanation 1 no Find items by value +syntax get explanation 1 no Gets value of item's property +syntax member explanation 1 no +syntax print explanation 1 no Print values of item properties +syntax remove explanation 1 no Remove item +syntax reset explanation 1 no +syntax set explanation 1 no Change item properties +``` + +Using that information, it is possible to query (even recursively) information about all menu items and sub-commands, starting from the root `/` command. + +:Warning: Since this feature is recent, trying to call it with our client package sometimes results in `terminal crush`. + +#### Using definition in file + +For this case, one needs: +1. Machine-readable data about available fields +2. Pass this data as `-inspect-definition-file` argument. + +To get resource definition, run the following command on remote system: +``` +$ :put [/console/inspect path=interface,list,add request=child as-value] +``` + +which produces: +``` +name=add;node-type=cmd;type=self;name=comment;node-type=arg;type=child;name=copy-from;node-type=arg;type=child;name=exclude;node-type=arg;type=child;name=include;node-type=arg;type=child;name=name;node-type=arg;type=child +``` + +If you have `ssh` access to the Mikrotik, the following command will produce the same string: +```shell +$ ssh -o Port=XXXX -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null admin@board ":put [/console inspect as-value request=child path=interface,list,add]" > inspect_definition.txt +``` + +After getting the definition file, just generate Mikrotik resource as usual with extra flag: +```sh +$ go run ./cmd/mikrotik-codegen mikrotik -name InterfaceList -commandBase "/interface/list" -inspect-definition-file ./inspect_definition.txt +``` +and all fields for the struct will be created. + + +Note, that we used `interface,list,add` as argument to `path`. The terminal equivalent would be `/interface/list/add` (not sure why it works that way, you can check [forum topic](https://forum.mikrotik.com/viewtopic.php?t=199139#p1024410)) + +The reason we used `add` command and not `/interface/list` menu itself, is that we need only args (fields) of `add` command - not information about possible commands for `/interface/list` +#### Query resource definition automatically + +To use this method, current environment must contain valid credentials. + +```shell +$ go run cmd/mikrotik-codegen/main.go mikrotik -commandBase /ip/dhcp-server -name DhcpServer -query-definition +``` diff --git a/cmd/mikrotik-codegen/internal/codegen/generator_mikrotik.go b/cmd/mikrotik-codegen/internal/codegen/generator_mikrotik.go index c30a0679..5cd92019 100644 --- a/cmd/mikrotik-codegen/internal/codegen/generator_mikrotik.go +++ b/cmd/mikrotik-codegen/internal/codegen/generator_mikrotik.go @@ -3,23 +3,37 @@ package codegen import ( "io" "text/template" + + consoleinspected "github.com/ddelnano/terraform-provider-mikrotik/client/console-inspected" + "github.com/ddelnano/terraform-provider-mikrotik/cmd/mikrotik-codegen/internal/utils" ) -func GenerateMikrotikResource(resourceName, commandBasePath string, w io.Writer) error { +func GenerateMikrotikResource(resourceName, commandBasePath string, + consoleCommandDefinition consoleinspected.ConsoleItem, + w io.Writer) error { if err := writeWrapper(w, []byte(generatedNotice)); err != nil { return err } t := template.New("resource") + t.Funcs(template.FuncMap{ + "pascalCase": utils.PascalCase, + }) if _, err := t.Parse(mikrotikResourceDefinitionTemplate); err != nil { return err } + fieldNames := make([]string, 0, len(consoleCommandDefinition.Arguments)) + for i := range consoleCommandDefinition.Arguments { + fieldNames = append(fieldNames, consoleCommandDefinition.Arguments[i].Name) + } data := struct { CommandBasePath string ResourceName string + FieldNames []string }{ CommandBasePath: commandBasePath, ResourceName: resourceName, + FieldNames: fieldNames, } return t.Execute(w, data) diff --git a/cmd/mikrotik-codegen/internal/codegen/templates.go b/cmd/mikrotik-codegen/internal/codegen/templates.go index fca16272..97527277 100644 --- a/cmd/mikrotik-codegen/internal/codegen/templates.go +++ b/cmd/mikrotik-codegen/internal/codegen/templates.go @@ -116,6 +116,9 @@ import ( // {{.ResourceName}} defines resource type {{.ResourceName}} struct { Id string ` + "`" + `mikrotik:".id"` + "`" + ` + {{range $fieldName := .FieldNames -}} + {{$fieldName | pascalCase}} string ` + "`" + `mikrotik:"{{$fieldName}}"` + "`" + ` + {{end}} } var _ Resource = (*{{.ResourceName}})(nil) diff --git a/cmd/mikrotik-codegen/internal/utils/utils.go b/cmd/mikrotik-codegen/internal/utils/utils.go index 4ab1721d..d5631542 100644 --- a/cmd/mikrotik-codegen/internal/utils/utils.go +++ b/cmd/mikrotik-codegen/internal/utils/utils.go @@ -1,6 +1,7 @@ package utils import ( + "regexp" "strings" ) @@ -35,3 +36,13 @@ func FirstLower(s string) string { return strings.ToLower(s[:1]) + s[1:] } + +// PascalCase makes every word in input string upper case and removes all not alpha-numeric symbols. +func PascalCase(s string) string { + r := regexp.MustCompile(`[^0-9a-zA-Z-]+`) + rClean := regexp.MustCompile(`[^0-9a-zA-Z]+`) + s = string(r.ReplaceAll([]byte(s), []byte("-"))) + s = strings.Title(s) + + return string(rClean.ReplaceAll([]byte(s), []byte(""))) +} diff --git a/cmd/mikrotik-codegen/internal/utils/utils_test.go b/cmd/mikrotik-codegen/internal/utils/utils_test.go index 11be21fe..0bfc2a56 100644 --- a/cmd/mikrotik-codegen/internal/utils/utils_test.go +++ b/cmd/mikrotik-codegen/internal/utils/utils_test.go @@ -1,6 +1,8 @@ package utils -import "testing" +import ( + "testing" +) func TestToSnakeCase(t *testing.T) { testCases := []struct { @@ -74,3 +76,46 @@ func TestFirstLower(t *testing.T) { }) } } +func TestPascalCase(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "already PascalCase", + input: "FieldNameInProperCase", + expected: "FieldNameInProperCase", + }, + { + name: "dashes", + input: "field-name-with-dashes", + expected: "FieldNameWithDashes", + }, + { + name: "dashes, underscores", + input: "field-name_with_dashes-and___underscores", + expected: "FieldNameWithDashesAndUnderscores", + }, + { + name: "other symbols", + input: "field/name with+++++different||||symbols", + expected: "FieldNameWithDifferentSymbols", + }, + { + name: "consecutive upper-cased if one-letter word", + input: "field/name with-a/b-testing", + expected: "FieldNameWithABTesting", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := PascalCase(tc.input) + if result != tc.expected { + t.Errorf(` + expected %s, + got %s`, tc.expected, result) + } + }) + } +} diff --git a/cmd/mikrotik-codegen/main.go b/cmd/mikrotik-codegen/main.go index 329de438..bb003072 100644 --- a/cmd/mikrotik-codegen/main.go +++ b/cmd/mikrotik-codegen/main.go @@ -10,13 +10,17 @@ import ( "os" "strconv" + "github.com/ddelnano/terraform-provider-mikrotik/client" + consoleinspected "github.com/ddelnano/terraform-provider-mikrotik/client/console-inspected" "github.com/ddelnano/terraform-provider-mikrotik/cmd/mikrotik-codegen/internal/codegen" ) type ( MikrotikConfiguration struct { - CommandBasePath string - ResourceName string + CommandBasePath string + ResourceName string + InspectDefinitionFile string + QueryDefinition bool } TerraformConfiguration struct { @@ -102,11 +106,41 @@ func realMain(args []string) error { commonFlags(fs, &destFile, &formatCode) fs.StringVar(&config.ResourceName, "name", "", "Name of the resource to generate.") fs.StringVar(&config.CommandBasePath, "commandBase", "/", "The command base path in MikroTik.") + fs.StringVar(&config.InspectDefinitionFile, "inspect-definition-file", "", + "[EXPERIMENTAL] File with command definition. Conflicts with query-definition.") + fs.BoolVar(&config.QueryDefinition, "query-definition", false, + "[EXPERIMENTAL] Query remote MikroTik device to fetch resource fields. Conflicts with inspect-definition-file.") _ = fs.Parse(args) + if config.InspectDefinitionFile != "" && config.QueryDefinition { + return errors.New("only one of inspect-definition-file or query-definition can be used") + } + + consoleCommandDefinition := consoleinspected.ConsoleItem{} + if config.InspectDefinitionFile != "" { + fileBytes, err := os.ReadFile(config.InspectDefinitionFile) + if err != nil { + return err + } + + consoleCommandDefinition, err = consoleinspected.Parse(string(fileBytes), consoleinspected.DefaultSplitStrategy) + if err != nil { + return err + } + } + + if config.QueryDefinition { + var err error + c := client.NewClient(client.GetConfigFromEnv()) + consoleCommandDefinition, err = c.InspectConsoleCommand(config.CommandBasePath + "/add") + if err != nil { + return err + } + } + generator = func() GeneratorFunc { return func(w io.Writer) error { - return codegen.GenerateMikrotikResource(config.ResourceName, config.CommandBasePath, w) + return codegen.GenerateMikrotikResource(config.ResourceName, config.CommandBasePath, consoleCommandDefinition, w) } } default: