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 }