mirror of
https://github.com/alibaba/higress.git
synced 2026-02-23 04:00:51 +08:00
384 lines
12 KiB
Go
384 lines
12 KiB
Go
// 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))
|
|
}
|