hgctl/pkg/plugin/install/install.go (300 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 ( "context" "encoding/json" "fmt" "io" "os" "strings" k8s "github.com/alibaba/higress/hgctl/pkg/kubernetes" "github.com/alibaba/higress/hgctl/pkg/plugin/build" "github.com/alibaba/higress/hgctl/pkg/plugin/config" "github.com/alibaba/higress/hgctl/pkg/plugin/option" "github.com/alibaba/higress/hgctl/pkg/plugin/types" "github.com/alibaba/higress/hgctl/pkg/plugin/utils" "github.com/alibaba/higress/pkg/cmd/options" "github.com/AlecAivazis/survey/v2/terminal" "github.com/pkg/errors" "github.com/santhosh-tekuri/jsonschema/v5" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" k8serr "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8syaml "k8s.io/apimachinery/pkg/util/yaml" cmdutil "k8s.io/kubectl/pkg/cmd/util" ) type installer struct { optionFile string bldOpts option.BuildOptions insOpts option.InstallOptions cli *k8s.WasmPluginClient w io.Writer utils.Debugger } func NewCommand() *cobra.Command { var ins installer v := viper.New() installCmd := &cobra.Command{ Use: "install", Aliases: []string{"ins", "i"}, Short: "Install WASM plugin", Example: ` # Install WASM plugin using a WasmPlugin manifest hgctl plugin install -y plugin-conf.yaml # Install WASM plugin through the Golang WASM plugin project (do it by relying on option.yaml now) docker login hgctl plugin install -g ./ `, PreRun: func(cmd *cobra.Command, args []string) { cmdutil.CheckErr(ins.config(v, cmd)) }, Run: func(cmd *cobra.Command, args []string) { cmdutil.CheckErr(ins.install(cmd.PersistentFlags())) }, } flags := installCmd.PersistentFlags() options.AddKubeConfigFlags(flags) option.AddOptionFileFlag(&ins.optionFile, flags) v.BindPFlags(flags) flags.StringP("namespace", "n", k8s.HigressNamespace, "Namespace where Higress was installed") v.BindPFlag("install.namespace", flags.Lookup("namespace")) v.SetDefault("install.namespace", k8s.DefaultHigressNamespace) flags.StringP("spec-yaml", "s", "./out/spec.yaml", "Use to validate WASM plugin configuration") v.BindPFlag("install.spec-yaml", flags.Lookup("spec-yaml")) v.SetDefault("install.spec-yaml", "./test/plugin-spec-yaml") // TODO(WeixinX): // - Change "--from-yaml (-y)" to "--from-oci (-o)" and implement command line interaction like "--from-go-src" // - Add "--from-jar (-j)" flags.StringP("from-yaml", "y", "./test/plugin-conf.yaml", "Install WASM plugin using a WasmPlugin manifest") v.BindPFlag("install.from-yaml", flags.Lookup("from-yaml")) v.SetDefault("install.from-yaml", "./test/plugin-conf.yaml") flags.StringP("from-go-src", "g", "", "Install WASM plugin through the Golang WASM plugin project") v.BindPFlag("install.from-go-src", flags.Lookup("from-go-src")) v.SetDefault("install.from-go-src", "") flags.BoolP("debug", "", false, "Enable debug mode") v.BindPFlag("install.debug", flags.Lookup("debug")) v.SetDefault("install.debug", false) return installCmd } func (ins *installer) config(v *viper.Viper, cmd *cobra.Command) error { allOpt, err := option.ParseOptions(ins.optionFile, v, cmd.PersistentFlags()) if err != nil { return err } // TODO(WeixinX): Avoid relying on build options, add a new option "--push/--image" for installing from go src ins.bldOpts = allOpt.Build ins.insOpts = allOpt.Install dynCli, err := k8s.NewDynamicClient(options.DefaultConfigFlags.ToRawKubeConfigLoader()) if err != nil { return errors.Wrap(err, "failed to build kubernetes dynamic client") } ins.cli = k8s.NewWasmPluginClient(dynCli) ins.w = cmd.OutOrStdout() ins.Debugger = utils.NewDefaultDebugger(ins.insOpts.Debug, ins.w) return nil } func (ins *installer) install(flags *pflag.FlagSet) (err error) { ins.Debugf("install option:\n%s\n", ins.String()) if ins.insOpts.FromGoSrc == "" || flags.Changed("from-yaml") { err = ins.yamlHandler() } else { err = ins.goHandler() } return } func (ins *installer) yamlHandler() error { return ins.doInstall(true) } func (ins *installer) goHandler() error { // 0. ensure output.type == image if ins.bldOpts.Output.Type != "image" { return errors.New("output type must be image") } // 1. build the WASM plugin project and push the image to the registry bld, err := build.NewBuilder(func(b *build.Builder) error { b.BuildOptions = ins.bldOpts b.Debug = ins.insOpts.Debug b.WithManualClean() // keep spec.yaml b.WithWriter(ins.w) return nil }) if err != nil { return errors.Wrap(err, "failed to initialize builder") } err = bld.Build() if err != nil { bld.Debugln("clean up for error ...") bld.CleanupForError() return errors.Wrap(err, "failed to build and push wasm plugin") } defer bld.Cleanup() // 2. command-line interaction lets the user enter the wasm plugin configuration specPath := bld.SpecYAMLPath() spec, err := types.ParseSpecYAML(specPath) if err != nil { return errors.Wrapf(err, "failed to parse spec.yaml: %s", specPath) } vld, err := buildSchemaValidator(spec) if err != nil { return err } example := spec.GetConfigExample() schema := spec.Spec.ConfigSchema.OpenAPIV3Schema printer := utils.DefaultPrinter() asker := NewWasmPluginSpecConfAsker( NewIngressAsker(bld.Model, schema, vld, printer), NewDomainAsker(bld.Model, schema, vld, printer), NewGlobalConfAsker(bld.Model, schema, vld, printer), printer, ) printer.Yesln("Please enter the configurations for the WASM plugin you want to install:") printer.Yesln("Configuration example:") printer.Yesf("\n%s\n", example) err = asker.Ask() if err != nil { if errors.Is(err, terminal.InterruptErr) { printer.Noln(askInterrupted) return nil } panic(err) } // 3. generate the WasmPlugin manifest wpc := asker.resp if err != nil { return errors.Wrap(err, "failed to marshal wasm plugin config") } // get the parameters of plugin-conf.yaml from spec.yaml pc, err := config.ExtractPluginConfFrom(spec, wpc.String(), bld.Output.Dest) if err != nil { return errors.Wrapf(err, "failed to get the parameters of plugin-conf.yaml from %s", specPath) } ins.Debugf("plugin-conf.yaml params:\n%s\n", pc.String()) if err = config.GenPluginConfYAML(pc, bld.TempDir()); err != nil { return errors.Wrap(err, "failed to generate plugin-conf.yaml") } // 4. install by the manifest ins.insOpts.FromYaml = bld.TempDir() + "/plugin-conf.yaml" if err = ins.doInstall(false); err != nil { return err } return nil } func (ins *installer) doInstall(validate bool) error { f, err := os.Open(ins.insOpts.FromYaml) if err != nil { return err } defer f.Close() // multiple WASM plugins are separated by '---' in yaml, but we only handle first one // TODO(WeixinX): Use WasmPlugin Object type instead of Unstructured obj := &unstructured.Unstructured{} dc := k8syaml.NewYAMLOrJSONDecoder(f, 4096) if err = dc.Decode(obj); err != nil { return errors.Wrapf(err, "failed to parse wasm plugin from manifest %q", ins.insOpts.FromYaml) } if !isValidAPIVersion(obj) { fmt.Fprintf(ins.w, "Warning: wasm plugin %q has invalid apiVersion, automatically modified: %q -> %q\n", obj.GetName(), obj.GetAPIVersion(), k8s.HigressExtAPIVersion) obj.SetAPIVersion(k8s.HigressExtAPIVersion) } if !isValidKind(obj) { fmt.Fprintf(ins.w, "Warning: wasm plugin %q has invalid kind, automatically modified: %q -> %q\n", obj.GetName(), obj.GetKind(), k8s.WasmPluginKind) obj.SetKind(k8s.WasmPluginKind) } if !isValidNamespace(obj) { fmt.Fprintf(ins.w, "Warning: wasm plugin %q has invalid namespace, automatically modified: %q -> %q\n", obj.GetName(), obj.GetNamespace(), k8s.HigressNamespace) obj.SetNamespace(k8s.HigressNamespace) } // validate wasm plugin config if validate { if wps, ok := obj.Object["spec"].(map[string]interface{}); ok { if err = ins.validateWasmPluginConfig(wps); err != nil { return err } } else { return errors.New("failed to get the spec filed of wasm plugin") } ins.Debugln("successfully validated wasm plugin config") } result, err := ins.cli.Create(context.TODO(), obj) if err != nil { if k8serr.IsAlreadyExists(err) { fmt.Fprintf(ins.w, "wasm plugin %q already exists\n", fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName())) return nil } return errors.Wrapf(err, "failed to install wasm plugin %q", fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName())) } fmt.Fprintf(ins.w, "Installed wasm plugin %q\n", fmt.Sprintf("%s/%s", result.GetNamespace(), result.GetName())) return nil } func isValidAPIVersion(obj *unstructured.Unstructured) bool { return obj.GetAPIVersion() == k8s.HigressExtAPIVersion } func isValidKind(obj *unstructured.Unstructured) bool { return obj.GetKind() == k8s.WasmPluginKind } func isValidNamespace(obj *unstructured.Unstructured) bool { return obj.GetNamespace() == k8s.HigressNamespace } func (ins *installer) validateWasmPluginConfig(wps map[string]interface{}) error { spec, err := types.ParseSpecYAML(ins.insOpts.SpecYaml) if err != nil { return errors.Wrapf(err, "failed to parse %s", ins.insOpts.SpecYaml) } vld, err := buildSchemaValidator(spec) if err != nil { return errors.Wrapf(err, "failed to build schema validator") } if dc, ok := wps["defaultConfig"].(map[string]interface{}); ok { if ok, err = validate(vld, dc); !ok { return errors.Wrap(err, "failed to validate default config") } // debug b, _ := utils.MarshalYamlWithIndent(dc, 2) ins.Debugf("default config:\n%s\n", string(b)) } if mrs, ok := wps["matchRules"].([]interface{}); ok { for _, mr := range mrs { if r, ok := mr.(map[string]interface{}); ok { if _, ok = r["ingress"]; ok { ing, err := decodeIngressMatchRule(r) if err != nil { return errors.Wrap(err, "failed to parse ingress match rule") } if ok, err = validate(vld, ing.Config); !ok { return errors.Wrap(err, "failed to validate ingress match rule") } ins.Debugf("ingress match rule:\n%s\n", ing.String()) } else if _, ok = r["domain"]; ok { dom, err := decodeDomainMatchRule(r) if err != nil { return errors.Wrap(err, "failed to parse domain match rule") } if ok, err = validate(vld, dom.Config); !ok { return errors.Wrap(err, "failed to validate ingress match rule") } ins.Debugf("domain match rule:\n%s\n", dom.String()) } } } } return nil } func buildSchemaValidator(spec *types.WasmPluginMeta) (*jsonschema.Schema, error) { if spec == nil { return nil, errors.New("spec is nil") } schema := spec.Spec.ConfigSchema.OpenAPIV3Schema if schema == nil { return nil, errors.New("spec has no config schema") } b, err := json.Marshal(schema) if err != nil { return nil, err } c := jsonschema.NewCompiler() c.Draft = jsonschema.Draft4 err = c.AddResource("schema.json", strings.NewReader(string(b))) vld, err := c.Compile("schema.json") if err != nil { errors.Wrap(err, "failed to compile schema") } return vld, nil } func (ins *installer) String() string { b, err := json.MarshalIndent(ins.insOpts, "", " ") if err != nil { return "" } return fmt.Sprintf("OptionFile: %s\n%s", ins.optionFile, string(b)) }