hgctl/pkg/plugin/install/asker.go (618 lines of code) (raw):

// Copyright (c) 2022 Alibaba Group Holding Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package install import ( "fmt" "strconv" "strings" "github.com/alibaba/higress/hgctl/pkg/plugin/types" "github.com/alibaba/higress/hgctl/pkg/plugin/utils" "github.com/AlecAivazis/survey/v2" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" "github.com/santhosh-tekuri/jsonschema/v5" ) const ( askInterrupted = "X Interrupted." invalidSyntax = "X Invalid syntax." failedToValidate = "X Failed to validate: not satisfied with schema." addConfSuccessful = "√ Successful to add configuration." ) var iconIdent = strings.Repeat(" ", 2) type Asker interface { Ask() error } type WasmPluginSpecConfAsker struct { resp *WasmPluginSpecConf ingAsk *IngressAsker domAsk *DomainAsker glcAsk *GlobalConfAsker printer *utils.YesOrNoPrinter } func NewWasmPluginSpecConfAsker(ingAsk *IngressAsker, domAsk *DomainAsker, glcAsk *GlobalConfAsker, printer *utils.YesOrNoPrinter) *WasmPluginSpecConfAsker { return &WasmPluginSpecConfAsker{ ingAsk: ingAsk, domAsk: domAsk, glcAsk: glcAsk, printer: printer, } } func (p *WasmPluginSpecConfAsker) Ask() error { var ( wpc = NewPluginSpecConf() globalConf map[string]interface{} ingressRule *IngressMatchRule domainRule *DomainMatchRule scopeA = newScopeAsker(p.printer) rewriteA = newRewriteAsker(p.printer) ruleA = newRuleAsker(p.printer) complete = false ) for { err := scopeA.Ask() if err != nil { return err } scope := scopeA.resp switch scope { case types.ScopeInstance: err = ruleA.Ask() if err != nil { return err } rule := ruleA.resp switch rule { case ruleIngress: if ingressRule != nil { p.printer.Yesf("\n%s\n", ingressRule) err = rewriteA.Ask() if err != nil { return err } if !rewriteA.resp { continue } } p.ingAsk.scope = scope err = p.ingAsk.Ask() if err != nil { return err } ingressRule = p.ingAsk.resp case ruleDomain: if domainRule != nil { p.printer.Yesf("\n%s\n", domainRule) err = rewriteA.Ask() if err != nil { return err } if !rewriteA.resp { continue } } p.domAsk.scope = scope err = p.domAsk.Ask() if err != nil { return err } domainRule = p.domAsk.resp } case types.ScopeGlobal: if globalConf != nil { b, _ := utils.MarshalYamlWithIndent(globalConf, 2) p.printer.Yesf("\n%s\n", string(b)) err = rewriteA.Ask() if err != nil { return err } if !rewriteA.resp { continue } } p.glcAsk.scope = scope err = p.glcAsk.Ask() if err != nil { return err } globalConf = p.glcAsk.resp case "Complete": complete = true break } if complete { break } } if globalConf != nil { wpc.DefaultConfig = globalConf } if ingressRule != nil { wpc.MatchRules = append(wpc.MatchRules, ingressRule) } if domainRule != nil { wpc.MatchRules = append(wpc.MatchRules, domainRule) } p.printer.Yesln("The complete configuration is as follows:") p.printer.Yesf("\n%s\n", wpc) p.resp = wpc return nil } type IngressAsker struct { resp *IngressMatchRule structName string schema *types.JSONSchemaProps scope types.Scope vld *jsonschema.Schema // for validation printer *utils.YesOrNoPrinter } func NewIngressAsker(structName string, schema *types.JSONSchemaProps, vld *jsonschema.Schema, printer *utils.YesOrNoPrinter) *IngressAsker { return &IngressAsker{ structName: structName, schema: schema, vld: vld, printer: printer, } } func (i *IngressAsker) Ask() error { continueA := newContinueAsker(i.printer) ings := make([]string, 0) for { var ing string err := utils.AskOne(&survey.Input{ Message: "Enter the matched ingress:", Help: "Matching ingress resource object, the matching format is: namespace/ingress name", }, &ing) if err != nil { return err } ing = strings.TrimSpace(ing) if ing != "" { ings = append(ings, ing) } err = continueA.Ask() if err != nil { return err } if !continueA.resp { break } } i.printer.Yesln(iconIdent + "Ingress:") as, err := recursivePrompt(i.structName, i.schema, i.scope, i.printer) if err != nil { return err } if ok, ve := validate(i.vld, as); !ok { i.printer.Noln(failedToValidate) i.printer.Noln(ve) return nil } i.resp = &IngressMatchRule{ Ingress: ings, Config: as, } i.printer.Yesln(addConfSuccessful) return nil } type DomainAsker struct { resp *DomainMatchRule structName string schema *types.JSONSchemaProps scope types.Scope vld *jsonschema.Schema // for validation printer *utils.YesOrNoPrinter } func NewDomainAsker(structName string, schema *types.JSONSchemaProps, vld *jsonschema.Schema, printer *utils.YesOrNoPrinter) *DomainAsker { return &DomainAsker{ structName: structName, schema: schema, vld: vld, printer: printer, } } func (d *DomainAsker) Ask() error { continueA := newContinueAsker(d.printer) doms := make([]string, 0) for { var dom string err := utils.AskOne(&survey.Input{ Message: "Enter the matched domain:", Help: "match domain name, support generic domain name", }, &dom) if err != nil { return err } dom = strings.TrimSpace(dom) if dom != "" { doms = append(doms, dom) } err = continueA.Ask() if err != nil { return err } if !continueA.resp { break } } d.printer.Yesln(iconIdent + "Domain:") as, err := recursivePrompt(d.structName, d.schema, d.scope, d.printer) if err != nil { return err } if ok, ve := validate(d.vld, as); !ok { d.printer.Noln(failedToValidate) d.printer.Noln(ve) return nil } d.resp = &DomainMatchRule{ Domain: doms, Config: as, } d.printer.Yesln(addConfSuccessful) return nil } type GlobalConfAsker struct { resp map[string]interface{} structName string schema *types.JSONSchemaProps scope types.Scope vld *jsonschema.Schema // for validation printer *utils.YesOrNoPrinter } func NewGlobalConfAsker(structName string, schema *types.JSONSchemaProps, vld *jsonschema.Schema, printer *utils.YesOrNoPrinter) *GlobalConfAsker { return &GlobalConfAsker{ structName: structName, schema: schema, vld: vld, printer: printer, } } func (g *GlobalConfAsker) Ask() error { g.printer.Yesln(iconIdent + "Global:") as, err := recursivePrompt(g.structName, g.schema, g.scope, g.printer) if err != nil { return err } if ok, ve := validate(g.vld, as); !ok { g.printer.Noln(failedToValidate) g.printer.Noln(ve) return nil } g.resp = as.(map[string]interface{}) g.printer.Yesln(addConfSuccessful) return nil } type continueAsker struct { resp bool printer *utils.YesOrNoPrinter } func newContinueAsker(printer *utils.YesOrNoPrinter) *continueAsker { return &continueAsker{printer: printer} } func (c *continueAsker) Ask() error { resp := true err := utils.AskOne(&survey.Confirm{ Message: fmt.Sprintf("%scontinue?", c.printer.Ident()), Default: true, }, &resp) if err != nil { return err } c.resp = resp return nil } type rewriteAsker struct { resp bool printer *utils.YesOrNoPrinter } func newRewriteAsker(printer *utils.YesOrNoPrinter) *rewriteAsker { return &rewriteAsker{printer: printer} } func (r *rewriteAsker) Ask() error { resp := false err := utils.AskOne(&survey.Confirm{ Message: fmt.Sprintf("%sThe configuration already exists as shown above. Do you want to rewrite it?", r.printer.Ident()), Default: false, }, &resp) if err != nil { return err } r.resp = resp return nil } type scopeAsker struct { resp types.Scope printer *utils.YesOrNoPrinter } func newScopeAsker(printer *utils.YesOrNoPrinter) *scopeAsker { return &scopeAsker{printer: printer} } func (s *scopeAsker) Ask() error { var resp string err := utils.AskOne(&survey.Select{ Message: fmt.Sprintf("%sChoose a configuration effective scope or complete:", s.printer.Ident()), Options: []string{ // TODO(WeixinX): Not visible to the user, instead Global, Ingress, and Domain are asked in ruleAsker string(types.ScopeInstance), string(types.ScopeGlobal), "Complete", }, Default: string(types.ScopeInstance), }, &resp) if err != nil { return err } s.resp = types.Scope(resp) return nil } type ruleAsker struct { resp Rule printer *utils.YesOrNoPrinter } func newRuleAsker(printer *utils.YesOrNoPrinter) *ruleAsker { return &ruleAsker{printer: printer} } func (r *ruleAsker) Ask() error { var resp string err := utils.AskOne(&survey.Select{ Message: fmt.Sprintf("%sChoose Ingress or Domain:", r.printer.Ident()), Options: []string{ string(ruleIngress), string(ruleDomain), }, Default: string(ruleIngress), }, &resp) if err != nil { return err } r.resp = Rule(resp) return nil } type WasmPluginSpecConf struct { DefaultConfig map[string]interface{} `yaml:"defaultConfig,omitempty"` MatchRules []MatchRule `yaml:"matchRules,omitempty"` } func NewPluginSpecConf() *WasmPluginSpecConf { return &WasmPluginSpecConf{ MatchRules: make([]MatchRule, 0), } } func (p *WasmPluginSpecConf) String() string { if len(p.DefaultConfig) == 0 && len(p.MatchRules) == 0 { return " " } b, _ := utils.MarshalYamlWithIndent(p, 2) return string(b) } type MatchRule interface { String() string } type IngressMatchRule struct { Ingress []string `json:"ingress" yaml:"ingress" mapstructure:"ingress"` Config interface{} `json:"config" yaml:"config" mapstructure:"config"` } func (i IngressMatchRule) String() string { b, _ := utils.MarshalYamlWithIndent(i, 2) return string(b) } func decodeIngressMatchRule(obj map[string]interface{}) (*IngressMatchRule, error) { var ing IngressMatchRule if err := mapstructure.Decode(obj, &ing); err != nil { return nil, err } return &ing, nil } type DomainMatchRule struct { Domain []string `json:"domain" yaml:"domain" mapstructure:"domain"` Config interface{} `json:"config" yaml:"config" mapstructure:"config"` } func (d DomainMatchRule) String() string { b, _ := utils.MarshalYamlWithIndent(d, 2) return string(b) } func decodeDomainMatchRule(obj map[string]interface{}) (*DomainMatchRule, error) { var dom DomainMatchRule if err := mapstructure.Decode(obj, &dom); err != nil { return nil, err } return &dom, nil } type Rule string const ( ruleIngress Rule = "Ingress" ruleDomain Rule = "Domain" ) func recursivePrompt(structName string, schema *types.JSONSchemaProps, selScope types.Scope, printer *utils.YesOrNoPrinter) (interface{}, error) { printer.IncIdentRepeat() defer printer.DecIndentRepeat() return doPrompt(structName, nil, schema, types.ScopeAll, selScope, printer) } func doPrompt(fieldName string, parent, schema *types.JSONSchemaProps, oriScope, selScope types.Scope, printer *utils.YesOrNoPrinter) (interface{}, error) { if schema.Title == "" { schema.Title = fieldName } if schema.Description == "" { schema.Description = fieldName } required := true if parent != nil { required = isRequired(fieldName, parent.Required) } msg, help := fieldTips(fieldName, parent, schema, required, printer) switch types.JsonType(schema.Type) { case types.JsonTypeObject: printer.Println(iconIdent + msg) obj := make(map[string]interface{}) m := schema.GetPropertiesOrderMap() for _, name := range m.Keys() { propI, _ := m.Get(name) prop := propI.(types.JSONSchemaProps) if parent == nil { // keep topmost scope if prop.Scope == types.ScopeGlobal { oriScope = types.ScopeGlobal } else if prop.Scope == types.ScopeInstance || prop.Scope == "" { oriScope = types.ScopeInstance } } if !matchesScope(oriScope, selScope, prop.Scope) { continue } printer.IncIdentRepeat() v, err := doPrompt(name, schema, &prop, oriScope, selScope, printer) printer.DecIndentRepeat() if err != nil { return nil, err } if v != nil { obj[name] = v } } if len(obj) == 0 { return nil, nil } return obj, nil case types.JsonTypeArray: printer.Println(iconIdent + msg) continueA := newContinueAsker(printer) arr := make([]interface{}, 0) for { printer.IncIdentRepeat() v, err := doPrompt("item", schema, schema.Items.Schema, oriScope, selScope, printer) if err != nil { printer.DecIndentRepeat() return nil, err } if v != nil { arr = append(arr, v) } err = continueA.Ask() printer.DecIndentRepeat() if err != nil { return nil, err } if !continueA.resp { break } } if len(arr) == 0 { return nil, nil } return arr, nil case types.JsonTypeInteger, types.JsonTypeNumber, types.JsonTypeBoolean, types.JsonTypeString: for { var inp string if err := utils.AskOne(&survey.Input{ Message: msg, Help: help, }, &inp); err != nil { return nil, err } if inp == "" && !required { return nil, nil } switch types.JsonType(schema.Type) { case types.JsonTypeInteger: v, err := strconv.ParseInt(inp, 10, 64) if err != nil { if errors.Is(err, strconv.ErrSyntax) { printer.Nof("%s %q type is invalid.\n", invalidSyntax, inp) continue } return nil, err } return v, nil case types.JsonTypeNumber: v, err := strconv.ParseFloat(inp, 64) if err != nil { if errors.Is(err, strconv.ErrSyntax) { printer.Nof("%s %q type is invalid.\n", invalidSyntax, inp) continue } return nil, err } return v, nil case types.JsonTypeBoolean: v, err := strconv.ParseBool(inp) if err != nil { if errors.Is(err, strconv.ErrSyntax) { printer.Nof("%s %q type is invalid.\n", invalidSyntax, inp) continue } return nil, err } return v, nil case types.JsonTypeString: return inp, nil default: return inp, nil } } default: return nil, fmt.Errorf("unsupported type: %s", schema.Type) } } func matchesScope(oriScope, selScope, scope types.Scope) bool { return (oriScope == selScope) || (selScope == types.ScopeInstance && (scope == selScope || scope == "" || scope == types.ScopeAll)) || (selScope == types.ScopeGlobal && (scope == selScope || scope == types.ScopeAll)) } func fieldTips(fieldName string, parent, schema *types.JSONSchemaProps, required bool, printer *utils.YesOrNoPrinter) (string, string) { var msg, help string if fieldName == "item" { msg = fmt.Sprintf("%s%s(%s)", printer.Ident(), fieldName, schema.Type) help = fmt.Sprintf("%s%s: %s", printer.Ident(), parent.Title, parent.Description) } else { req := schema.JoinRequirementsBy(types.I18nEN_US, required) msg = fmt.Sprintf("%s%s(%s, %s)", printer.Ident(), fieldName, schema.Type, req) help = fmt.Sprintf("%s%s: %s", printer.Ident(), schema.Title, schema.Description) } return msg, help } func isRequired(name string, required []string) bool { req := false for _, n := range required { if name == n { req = true break } } return req } func validate(schema *jsonschema.Schema, v interface{}) (bool, error) { if err := schema.Validate(v); err != nil { err = convertValidationError(err.(*jsonschema.ValidationError)) return false, err } return true, nil } func convertValidationError(ve *jsonschema.ValidationError) error { de := ve.DetailedOutput() if de.Valid { return nil } errs := make([]error, 0) if de.Error != "" { errs = append(errs, errors.New(de.Error)) } errs = append(errs, doConvertValidationError(de.Errors, errs)...) if len(errs) == 0 { return nil } var ret error for i, err := range errs { if i == 0 { ret = fmt.Errorf("%w", err) } else { ret = fmt.Errorf("%s\n%w", ret.Error(), err) } } return ret } func doConvertValidationError(de []jsonschema.Detailed, errs []error) []error { for _, e := range de { if e.Error != "" { errs = append(errs, errors.New(e.Error)) } if len(e.Errors) > 0 { errs = append(errs, doConvertValidationError(e.Errors, errs)...) } } return errs }