github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/generators/ddl/ddl.go (about)

     1  // Copyright (c) 2019-2022, R.I. Pienaar and the Choria Project contributors
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package ddl
     6  
     7  import (
     8  	"encoding/json"
     9  	"fmt"
    10  	"net/url"
    11  	"os"
    12  	"regexp"
    13  	"strings"
    14  
    15  	"github.com/AlecAivazis/survey/v2"
    16  	iu "github.com/choria-io/go-choria/internal/util"
    17  	ddl "github.com/choria-io/go-choria/providers/agent/mcorpc/ddl/agent"
    18  	"github.com/choria-io/go-choria/providers/agent/mcorpc/ddl/common"
    19  	"github.com/choria-io/go-choria/server/agents"
    20  )
    21  
    22  type Generator struct {
    23  	JSONOut      string
    24  	RubyOut      string
    25  	SkipVerify   bool
    26  	ForceConvert bool
    27  }
    28  
    29  func (c *Generator) ValidateJSON(agent *ddl.DDL) error {
    30  	// new validation library wants to only handle pure json type while the previous would take
    31  	// *agent.DDL and figure it out, now we have to take data and convert it to a basic type before
    32  	// validation
    33  	jd, err := json.Marshal(agent)
    34  	if err != nil {
    35  		return err
    36  	}
    37  	var d any
    38  	err = json.Unmarshal(jd, &d)
    39  	if err != nil {
    40  		return err
    41  	}
    42  
    43  	errs, err := iu.ValidateSchemaFromFS("schemas/mcorpc/ddl/v1/agent.json", d)
    44  	if err != nil {
    45  		return err
    46  	}
    47  	if len(errs) != 0 {
    48  		fmt.Printf("The generate DDL does not pass validation against https://choria.io/schemas/mcorpc/ddl/v1/agent.json:\n\n")
    49  		for _, err := range errs {
    50  			fmt.Printf(" - %s\n", err)
    51  		}
    52  
    53  		return fmt.Errorf("JSON DDL validation failed")
    54  	}
    55  
    56  	return nil
    57  }
    58  
    59  func (c *Generator) ConvertToRuby() error {
    60  	jddl, err := ddl.New(c.JSONOut)
    61  	if err != nil {
    62  		return err
    63  	}
    64  
    65  	if !c.SkipVerify {
    66  		fmt.Println("Validating JSON DDL against the schema...")
    67  		err = c.ValidateJSON(jddl)
    68  		if err != nil {
    69  			fmt.Printf("\nWARN: DDL does not pass JSON Schema Validation: %s\n", err)
    70  		}
    71  		fmt.Println()
    72  	}
    73  
    74  	rddl, err := jddl.ToRuby()
    75  	if err != nil {
    76  		return err
    77  	}
    78  
    79  	return os.WriteFile(c.RubyOut, []byte(rddl), 0644)
    80  }
    81  
    82  func (c *Generator) GenerateDDL() error {
    83  	agent := &ddl.DDL{
    84  		Schema:   "https://choria.io/schemas/mcorpc/ddl/v1/agent.json",
    85  		Metadata: &agents.Metadata{},
    86  		Actions:  []*ddl.Action{},
    87  	}
    88  
    89  	fmt.Println(`
    90  Choria Agents need a DDL file that describes the facilities provided by an
    91  agent, these files include:
    92  
    93  * Metadata about the agent such as who made it and its license
    94  * Every known action
    95    * Every input the action expects and its types, help and how to show it
    96    * Every output the action produce and its types, help and how to show it
    97    * How to summarize the returned outputs
    98  
    99  This tool assists in generating such a DDL file by interactively asking you questions.
   100  The JSON file is saved regularly after every major section of input, at any time
   101  "if you press ^C you'll get a partial JSON DDL with what you have already provided.
   102  
   103  These files are in JSON format and have a scheme, if you configure your editor
   104  to consume the schema you'll have a convenient way to modify the file after.
   105  	`)
   106  
   107  	survey.AskOne(&survey.Input{Message: "Press enter to start"}, &struct{}{})
   108  
   109  	err := c.askMetaData(agent)
   110  	if err != nil {
   111  		return err
   112  	}
   113  
   114  	err = c.saveDDL(agent)
   115  	if err != nil {
   116  		return err
   117  	}
   118  
   119  	err = c.askActions(agent)
   120  	if err != nil {
   121  		return err
   122  	}
   123  
   124  	err = c.saveDDL(agent)
   125  	if err != nil {
   126  		return err
   127  	}
   128  
   129  	if !c.SkipVerify {
   130  		fmt.Println("Validating JSON DDL against the schema...")
   131  		err = c.ValidateJSON(agent)
   132  		if err != nil {
   133  			fmt.Printf("WARN: DDL does not pass JSON Schema Validation: %s\n", err)
   134  		}
   135  		fmt.Println()
   136  	}
   137  
   138  	return nil
   139  }
   140  
   141  func (c *Generator) saveDDL(agent *ddl.DDL) error {
   142  	err := c.saveJSON(agent)
   143  	if err != nil {
   144  		return err
   145  	}
   146  
   147  	return c.saveRuby(agent)
   148  }
   149  
   150  func (c *Generator) saveRuby(agent *ddl.DDL) error {
   151  	if c.RubyOut == "" {
   152  		return nil
   153  	}
   154  
   155  	out, err := os.Create(c.RubyOut)
   156  	if err != nil {
   157  		return err
   158  	}
   159  	defer out.Close()
   160  
   161  	r, err := agent.ToRuby()
   162  	if err != nil {
   163  		return err
   164  	}
   165  
   166  	_, err = fmt.Fprint(out, r)
   167  	return err
   168  }
   169  
   170  func (c *Generator) saveJSON(agent *ddl.DDL) error {
   171  	out, err := os.Create(c.JSONOut)
   172  	if err != nil {
   173  		return err
   174  	}
   175  	defer out.Close()
   176  
   177  	j, err := json.MarshalIndent(agent, "", "  ")
   178  	if err != nil {
   179  		return err
   180  	}
   181  
   182  	_, err = fmt.Fprint(out, string(j))
   183  	return err
   184  }
   185  
   186  func (c *Generator) askBasicItem(name string, prompt string, help string, t survey.Transformer, v survey.Validator) *survey.Question {
   187  	return &survey.Question{
   188  		Name:      name,
   189  		Prompt:    &survey.Input{Message: prompt, Help: help},
   190  		Validate:  v,
   191  		Transform: t,
   192  	}
   193  }
   194  
   195  func (c *Generator) AskBool(m string) bool {
   196  	should := false
   197  	prompt := &survey.Confirm{
   198  		Message: m,
   199  	}
   200  	survey.AskOne(prompt, &should)
   201  	return should
   202  }
   203  
   204  func (c *Generator) askEnum(name string, prompt string, help string, valid []string, v survey.Validator) *survey.Question {
   205  	return &survey.Question{
   206  		Name:     name,
   207  		Prompt:   &survey.Select{Message: prompt, Help: help, Options: valid},
   208  		Validate: v,
   209  	}
   210  }
   211  
   212  func (c *Generator) showJSON(m string, d any) error {
   213  	j, err := json.MarshalIndent(d, "", "  ")
   214  	if err != nil {
   215  		return err
   216  	}
   217  
   218  	fmt.Println()
   219  	fmt.Println(m)
   220  	fmt.Println()
   221  	fmt.Println(string(j))
   222  	fmt.Println()
   223  
   224  	return nil
   225  }
   226  
   227  func (c *Generator) urlValidator(v any) error {
   228  	err := survey.Required(v)
   229  	if err != nil {
   230  		return err
   231  	}
   232  
   233  	vs, ok := v.(string)
   234  	if !ok {
   235  		return fmt.Errorf("should be a string")
   236  	}
   237  
   238  	u, err := url.ParseRequestURI(vs)
   239  	if !(err == nil && u.Scheme != "" && u.Host != "") {
   240  		return fmt.Errorf("is not a valid url")
   241  	}
   242  
   243  	return nil
   244  }
   245  
   246  func (c *Generator) semVerValidator(v any) error {
   247  	err := survey.Required(v)
   248  	if err != nil {
   249  		return err
   250  	}
   251  
   252  	vs, ok := v.(string)
   253  	if !ok {
   254  		return fmt.Errorf("should be a string")
   255  	}
   256  
   257  	if !regexp.MustCompile(`^\d+\.\d+\.\d+$`).MatchString(vs) {
   258  		return fmt.Errorf("must be basic semver x.y.z format")
   259  	}
   260  
   261  	return nil
   262  }
   263  
   264  func (c *Generator) shortnameValidator(v any) error {
   265  	err := survey.Required(v)
   266  	if err != nil {
   267  		return err
   268  	}
   269  
   270  	vs, ok := v.(string)
   271  	if !ok {
   272  		return fmt.Errorf("should be a string")
   273  	}
   274  
   275  	if !regexp.MustCompile(`^[a-z0-9_]*$`).MatchString(vs) {
   276  		return fmt.Errorf("must match ^[a-z0-9_]*$")
   277  	}
   278  
   279  	return nil
   280  }
   281  
   282  func (c *Generator) askActions(agent *ddl.DDL) error {
   283  	addAction := func() error {
   284  		action := &ddl.Action{
   285  			Input:       make(map[string]*common.InputItem),
   286  			Output:      make(map[string]*common.OutputItem),
   287  			Aggregation: []ddl.ActionAggregateItem{},
   288  		}
   289  
   290  		qs := []*survey.Question{
   291  			c.askBasicItem("name", "Action Name", "", survey.ToLower, func(v any) error {
   292  				act := v.(string)
   293  
   294  				if act == "" {
   295  					return fmt.Errorf("an action name is required")
   296  				}
   297  
   298  				if agent.HaveAction(act) {
   299  					return fmt.Errorf("already have an action %s", act)
   300  				}
   301  
   302  				return c.shortnameValidator(v)
   303  			}),
   304  
   305  			c.askBasicItem("description", "Description", "", nil, survey.Required),
   306  			c.askEnum("display", "Display Hint", "", []string{"ok", "failed", "always"}, survey.Required),
   307  		}
   308  
   309  		err := survey.Ask(qs, action)
   310  		if err != nil {
   311  			return err
   312  		}
   313  
   314  		agent.Actions = append(agent.Actions, action)
   315  
   316  		err = c.saveDDL(agent)
   317  		if err != nil {
   318  			return err
   319  		}
   320  
   321  		fmt.Println(`
   322  Arguments that you pass to an action are called inputs, an action can have
   323  any number of inputs - some being optional and some being required.
   324  
   325           Name: The name of the input argument
   326         Prompt: A short prompt to show when asking people this information
   327    Description: A 1 line description about this input
   328      Data Type: The type of data that this input must hold
   329       Optional: If this input is required or not
   330  	  Default: A default value when the input is not provided
   331  
   332  For string data there are additional properties:
   333  
   334     Max Length: How long a string may be, 0 for unlimited
   335     Validation: How to validate the string data
   336  		`)
   337  
   338  		for {
   339  			fmt.Println()
   340  
   341  			if len(action.InputNames()) > 0 {
   342  				fmt.Printf("Existing Inputs: %s\n\n", strings.Join(action.InputNames(), ", "))
   343  			}
   344  
   345  			if !c.AskBool("Add an input?") {
   346  				break
   347  			}
   348  
   349  			input := &common.InputItem{}
   350  			name := ""
   351  			survey.AskOne(&survey.Input{Message: "Input Name:"}, &name, survey.WithValidator(survey.Required), survey.WithValidator(func(v any) error {
   352  				i := v.(string)
   353  				if i == "" {
   354  					return fmt.Errorf("input name is required")
   355  				}
   356  
   357  				_, ok := action.Input[i]
   358  				if ok {
   359  					return fmt.Errorf("input %s already exist", i)
   360  				}
   361  
   362  				return c.shortnameValidator(v)
   363  			}))
   364  			qs := []*survey.Question{
   365  				c.askBasicItem("prompt", "Prompt", "", nil, survey.Required),
   366  				c.askBasicItem("description", "Description", "", nil, survey.Required),
   367  				c.askEnum("type", "Data Type", "", []string{"integer", "number", "float", "string", "boolean", "list", "hash", "array"}, survey.Required),
   368  				c.askBasicItem("optional", "Optional (t/f)", "", nil, survey.Required),
   369  			}
   370  
   371  			err = survey.Ask(qs, input)
   372  			if err != nil {
   373  				return err
   374  			}
   375  
   376  			if input.Type == "string" {
   377  				qs = []*survey.Question{
   378  					c.askBasicItem("maxlength", "Max Length", "", nil, survey.Required),
   379  					c.askEnum("validation", "Validation", "", []string{"shellsafe", "ipv4address", "ipv6address", "ipaddress", "regex"}, survey.Required),
   380  				}
   381  				err = survey.Ask(qs, input)
   382  				if err != nil {
   383  					return err
   384  				}
   385  
   386  				if input.Validation == "regex" {
   387  					survey.AskOne(&survey.Input{Message: "Validation Regular Expression"}, &input.Validation, survey.WithValidator(survey.Required))
   388  				}
   389  
   390  			} else if input.Type == "list" {
   391  				valid := ""
   392  				prompt := &survey.Input{
   393  					Message: "Valid Values (comma separated)",
   394  					Help:    "List of valid values for this input separated by commas",
   395  				}
   396  				err = survey.AskOne(prompt, &valid, survey.WithValidator(survey.Required))
   397  				if err != nil {
   398  					return err
   399  				}
   400  
   401  				input.Enum = strings.Split(valid, ",")
   402  			}
   403  
   404  			deflt := ""
   405  			err = survey.AskOne(&survey.Input{Message: "Default Value"}, &deflt)
   406  			if err != nil {
   407  				return err
   408  			}
   409  			if deflt != "" {
   410  				input.Default, err = common.ValToDDLType(input.Type, deflt)
   411  				if err != nil {
   412  					return fmt.Errorf("default for %s does not validate: %s", name, err)
   413  				}
   414  			}
   415  
   416  			action.Input[name] = input
   417  
   418  			err = c.saveDDL(agent)
   419  			if err != nil {
   420  				return err
   421  			}
   422  		}
   423  
   424  		fmt.Println(`
   425  Results that an action produce are called outputs, an action can have
   426  any number of outputs.
   427  
   428           Name: The name of the output
   429    Description: A 1 line description about this output
   430      Data Type: The type of data that this output must hold
   431     Display As: Hint to user interface on a heading to use for this data
   432  	Default: A default value when the output is not provided
   433  
   434  		`)
   435  
   436  		for {
   437  			fmt.Println()
   438  
   439  			if len(action.OutputNames()) > 0 {
   440  				fmt.Printf("Existing Outputs: %s\n\n", strings.Join(action.OutputNames(), ", "))
   441  			}
   442  
   443  			if !c.AskBool("Add an output?") {
   444  				break
   445  			}
   446  
   447  			output := &common.OutputItem{}
   448  			name := ""
   449  			survey.AskOne(&survey.Input{Message: "Name:"}, &name, survey.WithValidator(survey.Required), survey.WithValidator(func(v any) error {
   450  				i := v.(string)
   451  				if i == "" {
   452  					return fmt.Errorf("output name is required")
   453  				}
   454  
   455  				_, ok := action.Output[i]
   456  				if ok {
   457  					return fmt.Errorf("output %s already exist", i)
   458  				}
   459  
   460  				return c.shortnameValidator(v)
   461  			}))
   462  			qs := []*survey.Question{
   463  				c.askBasicItem("description", "Description", "", nil, survey.Required),
   464  				c.askEnum("type", "Data Type", "", []string{"integer", "number", "float", "string", "boolean", "list", "hash", "array"}, survey.Required),
   465  				c.askBasicItem("displayas", "Display As", "", nil, survey.Required),
   466  			}
   467  
   468  			err = survey.Ask(qs, output)
   469  			if err != nil {
   470  				return err
   471  			}
   472  
   473  			deflt := ""
   474  			err = survey.AskOne(&survey.Input{Message: "Default Value"}, &deflt)
   475  			if err != nil {
   476  				return err
   477  			}
   478  
   479  			if deflt != "" {
   480  				output.Default, err = common.ValToDDLType(output.Type, deflt)
   481  				if err != nil {
   482  					return fmt.Errorf("default for %s does not validate: %s", name, err)
   483  				}
   484  			}
   485  
   486  			action.Output[name] = output
   487  			err = c.saveDDL(agent)
   488  			if err != nil {
   489  				return err
   490  			}
   491  		}
   492  
   493  		c.showJSON("Resulting Action", action)
   494  
   495  		return nil
   496  	}
   497  
   498  	fmt.Println(`
   499  An action is a hosted piece of logic that can be called remotely and
   500  it takes input arguments and produce output data.
   501  
   502  For example a package management agent would have actions like install,
   503  uninstall, status and more.
   504  
   505  Every agent can have as many actions as you want, we'll now prompt
   506  for them until you are satisfied you added them all.
   507  
   508            Name: The name of the action, like "install"
   509     Description: A short 1 liner describing the purpose of the action
   510         Display: A hint to client tools about when to show the data,
   511                  when interacting with 1000 nodes it's easy to miss
   512                  the one that had a failure, setting this to "failed"
   513                  will tell UIs to only show ones that failed.
   514  
   515                  Likewise "ok" for only successful ones and "always" to
   516                  show all results.
   517  	`)
   518  
   519  	for {
   520  		fmt.Println()
   521  
   522  		if len(agent.ActionNames()) > 0 {
   523  			fmt.Printf("Existing Actions: %s\n\n", strings.Join(agent.ActionNames(), ", "))
   524  		}
   525  
   526  		if !c.AskBool("Add an action?") {
   527  			break
   528  		}
   529  
   530  		err := addAction()
   531  		if err != nil {
   532  			return err
   533  		}
   534  
   535  		err = c.saveDDL(agent)
   536  		if err != nil {
   537  			return err
   538  		}
   539  	}
   540  
   541  	return nil
   542  }
   543  
   544  func (c *Generator) askMetaData(agent *ddl.DDL) error {
   545  	fmt.Println(`
   546  First we need to gather meta data about the agent such as it's author, version and more
   547  this metadata is used to keep an internal inventory of all the available services.
   548  
   549           Name: The name the agent would be reachable as, example package, acme_util
   550    Description: A short 1 liner description of the agent
   551         Author: Contact details to reach the author
   552        Version: Version in SemVer format
   553        License: The license to use, typically one in https://spdx.org/licenses/
   554            URL: A URL one can visit for further information about the agent
   555        Timeout: Maximum time in seconds any action will be allowed to run
   556       Provider: The provider to use - ruby for traditional mcollective ones,
   557                 external for ones complying to the External Agent structure,
   558                 golang for ones delivered as a native Go plugin.
   559        Service: Indicates an agent will be a service, hosted in a load sharing
   560                 group rather than 1:n as normal agents.\n`)
   561  
   562  	qs := []*survey.Question{
   563  		c.askBasicItem("name", "Agent Name", "", survey.ToLower, c.shortnameValidator),
   564  		c.askBasicItem("description", "Description", "", nil, survey.Required),
   565  		c.askBasicItem("author", "Author", "", nil, survey.Required),
   566  		c.askBasicItem("version", "Version", "", survey.ToLower, c.semVerValidator),
   567  		c.askBasicItem("license", "License", "", nil, survey.Required),
   568  		c.askBasicItem("url", "URL", "", survey.ToLower, c.urlValidator),
   569  		c.askBasicItem("timeout", "Timeout", "", nil, survey.Required),
   570  		c.askEnum("provider", "Backend Provider", "", []string{"ruby", "external", "golang"}, nil),
   571  	}
   572  
   573  	err := survey.Ask(qs, agent.Metadata)
   574  	if err != nil {
   575  		return err
   576  	}
   577  
   578  	agent.Metadata.Service = c.AskBool("Service")
   579  
   580  	c.showJSON("Resulting metadata", agent.Metadata)
   581  
   582  	return nil
   583  }