Record the progress of the OSPP 2023 hgctl project (#453)

This commit is contained in:
WeixinX
2023-10-27 17:36:49 +08:00
committed by GitHub
parent 4a5127fedc
commit 901ad9619d
41 changed files with 7178 additions and 101 deletions

View File

@@ -0,0 +1,111 @@
// 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 docker
import (
"context"
"io"
"strings"
"github.com/compose-spec/compose-go/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/flags"
"github.com/docker/compose/v2/cmd/formatter"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
)
type Compose struct {
client *api.ServiceProxy
w io.Writer
}
func NewCompose(w io.Writer) (*Compose, error) {
c := &Compose{w: w}
dockerCli, err := command.NewDockerCli(
command.WithCombinedStreams(c.w),
// command.WithDefaultContextStoreConfig(), Deprecated, set during NewDockerCli
)
if err != nil {
return nil, err
}
opts := flags.NewClientOptions()
err = dockerCli.Initialize(opts)
if err != nil {
return nil, err
}
c.client = api.NewServiceProxy().WithService(compose.NewComposeService(dockerCli.Client(), dockerCli.ConfigFile()))
return c, nil
}
func (c Compose) Up(ctx context.Context, name string, configs []string, source string, detach bool) error {
pOpts, err := cli.NewProjectOptions(
configs,
cli.WithWorkingDirectory(source),
cli.WithDefaultConfigPath,
cli.WithName(name),
)
if err != nil {
return err
}
project, err := cli.ProjectFromOptions(pOpts)
if err != nil {
return err
}
for i, s := range project.Services {
// TODO(WeixinX): Change from `Label` to `CustomLabels` after upgrading the dependency library github.com/compose-spec/compose-go
s.Labels = map[string]string{
api.ProjectLabel: project.Name,
api.ServiceLabel: s.Name,
api.VersionLabel: api.ComposeVersion,
api.WorkingDirLabel: project.WorkingDir,
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
api.OneoffLabel: "False",
}
project.Services[i] = s
}
project.WithoutUnnecessaryResources()
// for log
var consumer api.LogConsumer
if !detach {
// TODO(WeixinX): Change to `formatter.NewLogConsumer(ctx, c.w, c.w, true, true, false)` after upgrading the dependency library github.com/compose-spec/compose-go
consumer = formatter.NewLogConsumer(ctx, c.w, true, true)
}
attachTo := make([]string, 0)
for _, svc := range project.Services {
attachTo = append(attachTo, svc.Name)
}
return c.client.Up(ctx, project, api.UpOptions{
Start: api.StartOptions{
Attach: consumer,
AttachTo: attachTo,
},
})
}
func (c Compose) List(ctx context.Context) ([]api.Stack, error) {
return c.client.List(ctx, api.ListOptions{})
}
func (c Compose) Down(ctx context.Context, name string) error {
return c.client.Down(ctx, name, api.DownOptions{})
}

View File

@@ -0,0 +1,130 @@
// 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 kubernetes
import (
"context"
"github.com/spf13/pflag"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
const (
DefaultHigressNamespace = "higress-system"
HigressExtGroup = "extensions.higress.io"
HigressExtVersion = "v1alpha1"
HigressExtAPIVersion = HigressExtGroup + "/" + HigressExtVersion
WasmPluginKind = "WasmPlugin"
WasmPluginResource = "wasmplugins"
)
var (
HigressNamespace = DefaultHigressNamespace
WasmPluginGVK = schema.GroupVersionKind{Group: HigressExtGroup, Version: HigressExtVersion, Kind: WasmPluginKind}
WasmPluginGVR = schema.GroupVersionResource{Group: HigressExtGroup, Version: HigressExtVersion, Resource: WasmPluginResource}
)
func AddHigressNamespaceFlags(flags *pflag.FlagSet) {
flags.StringVarP(&HigressNamespace, "namespace", "n",
DefaultHigressNamespace, "Namespace where Higress was installed")
}
type WasmPluginClient struct {
dyn *DynamicClient
}
func NewWasmPluginClient(dynClient *DynamicClient) *WasmPluginClient {
return &WasmPluginClient{dynClient}
}
func (c WasmPluginClient) Get(ctx context.Context, name string) (*unstructured.Unstructured, error) {
return c.dyn.Get(ctx, WasmPluginGVR, HigressNamespace, name)
}
func (c WasmPluginClient) List(ctx context.Context) (*unstructured.UnstructuredList, error) {
return c.dyn.List(ctx, WasmPluginGVR, HigressNamespace)
}
func (c WasmPluginClient) Create(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
return c.dyn.Create(ctx, WasmPluginGVR, HigressNamespace, obj)
}
func (c WasmPluginClient) Delete(ctx context.Context, name string) (*unstructured.Unstructured, error) {
return c.dyn.Delete(ctx, WasmPluginGVR, HigressNamespace, name)
}
func (c WasmPluginClient) Update(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
return c.dyn.Update(ctx, WasmPluginGVR, HigressNamespace, obj)
}
// TODO(WeixinX): Will be changed to WasmPlugin specific Client instead of Unstructured
type DynamicClient struct {
config *rest.Config
client dynamic.Interface
}
func NewDynamicClient(clientConfig clientcmd.ClientConfig) (*DynamicClient, error) {
var (
c DynamicClient
err error
)
c.config, err = clientConfig.ClientConfig()
if err != nil {
return nil, err
}
c.client, err = dynamic.NewForConfig(c.config)
if err != nil {
return nil, err
}
return &c, nil
}
func (c DynamicClient) Get(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
return c.client.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
}
func (c DynamicClient) List(ctx context.Context, gvr schema.GroupVersionResource, namespace string) (*unstructured.UnstructuredList, error) {
return c.client.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
}
func (c DynamicClient) Create(ctx context.Context, gvr schema.GroupVersionResource, namespace string, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
return c.client.Resource(gvr).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{})
}
func (c DynamicClient) Delete(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
result, err := c.client.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, err
}
err = c.client.Resource(gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
if err != nil {
return nil, err
}
return result, nil
}
func (c DynamicClient) Update(ctx context.Context, gvr schema.GroupVersionResource, namespace string,
obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
return c.client.Resource(gvr).Namespace(namespace).Update(ctx, obj, metav1.UpdateOptions{})
}

View File

@@ -0,0 +1,778 @@
// 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 build
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"os/user"
"strings"
"syscall"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
ptypes "github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
const (
DefaultBuilderRepository = "higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder"
DefaultBuilderGo = "1.19"
DefaultBuilderTinyGo = "0.28.1"
DefaultBuilderOras = "1.0.0"
MediaTypeSpec = "application/vnd.module.wasm.spec.v1+yaml"
MediaTypeREADME = "application/vnd.module.wasm.doc.v1+markdown"
MediaTypeREADME_ZH = "application/vnd.module.wasm.doc.v1.zh+markdown"
MediaTypeREADME_EN = "application/vnd.module.wasm.doc.v1.en+markdown"
MediaTypeIcon = "application/vnd.module.wasm.icon.v1+png"
MediaTypePlugin = "application/vnd.oci.image.layer.v1.tar+gzip"
HostTempDirPattern = "higress-wasm-go-build-*"
HostDockerEntryPattern = "higress-wasm-go-build-docker-entrypoint-*.sh"
ContainerWorkDir = "/workspace"
ContainerTempDir = "/higress_temp" // the directory to temporarily store the build products
ContainerOutDir = "/output"
ContainerDockerAuth = "/root/.docker/config.json"
ContainerEntryFile = "docker-entrypoint.sh"
ContainerEntryFilePath = "/" + ContainerEntryFile
)
type Builder struct {
OptionFile string
option.BuildOptions
Username, Password string
repository string
tempDir string
dockerEntrypoint string
uid, gid string
manualClean bool
containerID string
containerConf types.ContainerCreateConfig
dockerCli *client.Client
w io.Writer
sig chan os.Signal // watch interrupt
stop chan struct{} // stop the build process when an interruption occurs
done chan struct{} // signal that the build process is finished
utils.Debugger
*utils.YesOrNoPrinter
}
func NewBuilder(f ConfigFunc) (*Builder, error) {
b := new(Builder)
if err := b.config(f); err != nil {
return nil, err
}
return b, nil
}
func NewCommand() *cobra.Command {
var bld Builder
v := viper.New()
buildCmd := &cobra.Command{
Use: "build",
Aliases: []string{"bld", "b"},
Short: "Build Golang WASM plugin",
Example: ` # If the option.yaml file exists in the current path, do the following:
hgctl plugin build
# Using "--model(-s)" to specify the WASM plugin configuration structure name, e.g. "HelloWorldConfig"
hgctl plugin build --model HelloWorldConfig
# Using "--output-type(-t)" and "--output-dest(-d)" to push the build products as an OCI image to the specified repository
docker login
hgctl plugin build -s BasicAuthConfig -t image -d docker.io/<your_username>/<your_image>
`,
PreRun: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(bld.config(func(b *Builder) error {
return b.parseOptions(v, cmd)
}))
},
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(bld.Build())
},
}
bld.bindFlags(v, buildCmd.PersistentFlags())
return buildCmd
}
func (b *Builder) bindFlags(v *viper.Viper, flags *pflag.FlagSet) {
option.AddOptionFileFlag(&b.OptionFile, flags)
flags.StringVarP(&b.Username, "username", "u", "", "Username for pushing image to the docker repository")
flags.StringVarP(&b.Password, "password", "p", "", "Password for pushing image to the docker repository")
v.BindPFlags(flags)
// this binding ensures that flags explicitly set on the command line have the
// highest priority, and if they are not set, they are read from the configuration file.
flags.StringP("builder-go", "g", DefaultBuilderGo, "Golang version in the official builder image")
v.BindPFlag("build.builder.go", flags.Lookup("builder-go"))
v.SetDefault("build.builder.go", DefaultBuilderGo)
flags.StringP("builder-tinygo", "n", DefaultBuilderTinyGo, "TinyGo version in the official builder image")
v.BindPFlag("build.builder.tinygo", flags.Lookup("builder-tinygo"))
v.SetDefault("build.builder.tinygo", DefaultBuilderTinyGo)
flags.StringP("builder-oras", "r", DefaultBuilderOras, "ORAS version in official the builder image")
v.BindPFlag("build.builder.oras", flags.Lookup("builder-oras"))
v.SetDefault("build.builder.oras", DefaultBuilderOras)
flags.StringP("input", "i", "./", "Directory of the WASM plugin project to be built")
v.BindPFlag("build.input", flags.Lookup("input"))
v.SetDefault("build.input", "./")
flags.StringP("output-type", "t", "files", "Output type of the build products. [files, image]")
v.BindPFlag("build.output.type", flags.Lookup("output-type"))
v.SetDefault("build.output.type", "files")
flags.StringP("output-dest", "d", "./out", "Output destination of the build products")
v.BindPFlag("build.output.dest", flags.Lookup("output-dest"))
v.SetDefault("build.output.dest", "./out")
flags.StringP("docker-auth", "a", "~/.docker/config.json", "Authentication configuration for pushing image to the docker repository")
v.BindPFlag("build.docker-auth", flags.Lookup("docker-auth"))
v.SetDefault("build.docker-auth", "~/.docker/config.json")
flags.StringP("model-dir", "m", "./", "Directory of the WASM plugin configuration structure")
v.BindPFlag("build.model-dir", flags.Lookup("model-dir"))
v.SetDefault("build.model-dir", "./")
flags.StringP("model", "s", "", "Structure name of the WASM plugin configuration")
v.BindPFlag("build.model", flags.Lookup("model"))
v.SetDefault("build.model", "PluginConfig")
flags.BoolP("debug", "", false, "Enable debug mode")
v.BindPFlag("build.debug", flags.Lookup("debug"))
v.SetDefault("build.debug", false)
}
func (b *Builder) Build() (err error) {
b.Debugf("build options: \n%s\n", b.String())
go func() {
err = b.doBuild()
}()
// wait for an interruption to occur or finishing the build
select {
case <-b.sig:
b.interrupt()
b.Nof("\nInterrupt ...\n")
// wait for the doBuild process to exit, otherwise there will be unexpected bugs
b.waitForFinished()
// if the build process is interrupted, then we ignore the flag `manualClean` and clean up
// TODO(WeixinX): How do we clean up uploaded image when an interruption occurs?
b.Debugln("clean up for interrupting ...")
b.CleanupForError()
os.Exit(0)
case <-b.done:
if err != nil {
if !b.manualClean {
b.Debugln("clean up for error ...")
b.CleanupForError()
}
return
}
if !b.manualClean {
b.Debugln("clean up for normal ...")
b.Cleanup()
}
}
return
}
var (
waitIcon = "[-]"
successfulIcon = "[√]"
)
func (b *Builder) doBuild() (err error) {
// finish here does not mean that the build was successful,
// but that the doBuild process is complete
defer b.finish()
if err = b.generateMetadata(); err != nil {
return errors.Wrap(err, "failed to generate wasm plugin metadata files")
}
b.Printf("%s pull the builder image ...\n", waitIcon)
ctx := context.TODO()
if err = b.imagePull(ctx); err != nil {
return errors.Wrapf(err, "failed to pull the builder image %s", b.builderImageRef())
}
b.Yesf("%s pull the builder image: %s\n", successfulIcon, b.builderImageRef())
if err = b.addContainerConfByOutType(); err != nil {
return errors.Wrapf(err, "failed to add the additional container configuration for output type %q", b.Output.Type)
}
b.Printf("%s create the builder container ...\n", waitIcon)
if err = b.containerCreate(ctx); err != nil {
return errors.Wrap(err, "failed to create the builder container")
}
b.Yesf("%s create the builder container: %s\n", successfulIcon, b.containerID)
b.Printf("%s start the builder container ...\n", waitIcon)
if err = b.containerStart(ctx); err != nil {
return errors.Wrap(err, "failed to start the builder container")
}
if b.Output.Type == "files" {
b.Yesf("%s finish building!\n", successfulIcon)
} else if b.Output.Type == "image" {
b.Yesf("%s finish building and pushing!\n", successfulIcon)
}
return nil
}
var errBuildAbort = errors.New("build aborted")
func (b *Builder) generateMetadata() error {
// spec.yaml
if b.isInterrupted() {
return errBuildAbort
}
spec, err := os.Create(b.SpecYAMLPath())
if err != nil {
return err
}
defer spec.Close()
meta, err := ptypes.ParseGoSrc(b.ModelDir, b.Model)
if err != nil {
return err
}
if err = utils.MarshalYamlWithIndentTo(spec, meta, 2); err != nil {
return err
}
// TODO(WeixinX): More languages need to be supported
// README.md is required, README_{lang}.md is optional
if b.isInterrupted() {
return errBuildAbort
}
usages, err := meta.GetUsages()
if err != nil {
return errors.Wrap(err, "failed to get wasm usage")
}
for i, u := range usages {
// since `usages` are ordered by `I18nType` and currently only `en-US` and
// `zh-CN` are available, en-US is the default README.md language when en-US is
// present (because after sorting it is in the first place)
suffix := true
if i == 0 {
suffix = false
}
if err = genMarkdownUsage(&u, b.tempDir, suffix); err != nil {
return err
}
}
return nil
}
func (b *Builder) imagePull(ctx context.Context) error {
if b.isInterrupted() {
return errBuildAbort
}
r, err := b.dockerCli.ImagePull(ctx, b.builderImageRef(), types.ImagePullOptions{})
if err != nil {
return err
}
if b.isInterrupted() {
return errBuildAbort
}
io.Copy(b.w, r)
return nil
}
func (b *Builder) addContainerConfByOutType() error {
if b.isInterrupted() {
return errBuildAbort
}
var err error
switch b.Output.Type {
case "files":
err = b.filesHandler()
case "image":
err = b.imageHandler()
default:
return errors.New("invalid output option, output type is unknown")
}
if err != nil {
return err
}
return nil
}
func (b *Builder) containerCreate(ctx context.Context) error {
if b.isInterrupted() {
return errBuildAbort
}
resp, err := b.dockerCli.ContainerCreate(ctx, b.containerConf.Config, b.containerConf.HostConfig,
b.containerConf.NetworkingConfig, b.containerConf.Platform, b.containerConf.Name)
if err != nil {
return err
}
b.containerID = resp.ID
return nil
}
func (b *Builder) containerStart(ctx context.Context) error {
if b.isInterrupted() {
return errBuildAbort
}
if err := b.dockerCli.ContainerStart(ctx, b.containerID, types.ContainerStartOptions{}); err != nil {
return err
}
if b.isInterrupted() {
return errBuildAbort
}
statusCh, errCh := b.dockerCli.ContainerWait(ctx, b.containerID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
return err
}
case <-statusCh:
}
if b.isInterrupted() {
return errBuildAbort
}
logs, err := b.dockerCli.ContainerLogs(ctx, b.containerID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
return err
}
if b.isInterrupted() {
return errBuildAbort
}
_, err = stdcopy.StdCopy(b.w, b.w, logs)
if err != nil {
return err
}
return nil
}
var errWriteDockerEntrypoint = errors.New("failed to write docker entrypoint")
func (b *Builder) filesHandler() error {
b.containerConf.HostConfig.Mounts = append(b.containerConf.HostConfig.Mounts, mount.Mount{
// output dir for the build products
Type: mount.TypeBind,
Source: b.Output.Dest,
Target: ContainerOutDir,
})
ft := &FilesTmplFields{
BuildSrcDir: ContainerWorkDir,
BuildDestDir: ContainerTempDir,
Output: ContainerOutDir,
UID: b.uid,
GID: b.uid,
Debug: b.Debug,
}
if err := genFilesDockerEntrypoint(ft, b.dockerEntrypoint); err != nil {
return errors.Wrap(err, errWriteDockerEntrypoint.Error())
}
return nil
}
var (
optionalProducts = [][2]string{
{"README_ZH.md", MediaTypeREADME_ZH},
{"README_EN.md", MediaTypeREADME_EN},
{"icon.png", MediaTypeIcon},
}
)
// TODO(WeixinX): If the image exists, no push is performed
func (b *Builder) imageHandler() error {
products := ""
for i, p := range optionalProducts {
fileName := p[0]
mediaType := p[1]
if i == 0 {
products = fmt.Sprintf("%s %s", fileName, mediaType)
} else {
products = fmt.Sprintf("%s %s %s", products, fileName, mediaType)
}
}
// spec.yaml, README.md and plugin.tar.gz are required
basicCmd := fmt.Sprintf("oras push %s -u %s -p %s ./spec.yaml:%s ./README.md:%s",
b.Output.Dest, b.Username, b.Password, MediaTypeSpec, MediaTypeREADME)
if b.Username == "" || b.Password == "" {
basicCmd = fmt.Sprintf("oras push %s ./spec.yaml:%s ./README.md:%s",
b.Output.Dest, MediaTypeSpec, MediaTypeREADME)
b.containerConf.HostConfig.Mounts = append(b.containerConf.HostConfig.Mounts, mount.Mount{
// docker auth
Type: mount.TypeBind,
Source: b.DockerAuth,
Target: ContainerDockerAuth,
})
}
it := &ImageTmplFields{
BuildSrcDir: ContainerWorkDir,
BuildDestDir: ContainerTempDir,
Output: ContainerOutDir,
Username: b.Username,
Password: b.Password,
BasicCmd: basicCmd,
Products: products,
MediaTypePlugin: MediaTypePlugin,
Debug: b.Debug,
}
if err := genImageDockerEntrypoint(it, b.dockerEntrypoint); err != nil {
return errors.Wrap(err, errWriteDockerEntrypoint.Error())
}
return nil
}
// ConfigFunc is customized to set the fields of Builder
type ConfigFunc func(b *Builder) error
func (b *Builder) config(f ConfigFunc) (err error) {
if err = f(b); err != nil {
return err
}
// builder-go
b.Builder.Go = strings.TrimSpace(b.Builder.Go)
if b.Builder.Go == "" {
b.Builder.Go = DefaultBuilderGo
}
// builder-tinygo
b.Builder.TinyGo = strings.TrimSpace(b.Builder.TinyGo)
if b.Builder.TinyGo == "" {
b.Builder.TinyGo = DefaultBuilderTinyGo
}
// builder-oras
b.Builder.Oras = strings.TrimSpace(b.Builder.Oras)
if b.Builder.Oras == "" {
b.Builder.Oras = DefaultBuilderOras
}
// input
b.Input = strings.TrimSpace(b.Input)
if b.Input == "" {
b.Input = "./"
}
inp, err := utils.GetAbsolutePath(b.Input)
if err != nil {
return errors.Wrapf(err, "failed to parse input option %q", b.Input)
}
b.Input = inp
// output-type
b.Output.Type = strings.ToLower(strings.TrimSpace(b.Output.Type))
if b.Output.Type == "" {
b.Output.Type = "files"
}
if b.Output.Type != "files" && b.Output.Type != "image" {
return errors.Errorf("invalid output type: %q, must be `files` or `image`", b.Output.Type)
}
// output-dest
b.Output.Dest = strings.TrimSpace(b.Output.Dest)
if b.Output.Dest == "" {
b.Output.Dest = "./out"
}
out := b.Output.Dest
if b.Output.Type == "files" {
out, err = utils.GetAbsolutePath(b.Output.Dest)
if err != nil {
return errors.Wrapf(err, "failed to parse output destination %q", b.Output.Dest)
}
err = os.MkdirAll(b.Output.Dest, 0755)
if err != nil && !os.IsExist(err) {
return errors.Wrapf(err, "failed to create output destination %q", b.Output.Dest)
}
}
b.Output.Dest = out
// docker-auth
b.DockerAuth = strings.TrimSpace(b.DockerAuth)
if b.DockerAuth == "" {
b.DockerAuth = "~/.docker/config.json"
}
auth, err := utils.GetAbsolutePath(b.DockerAuth)
if err != nil {
return errors.Wrapf(err, "failed to parse docker authentication %q", b.DockerAuth)
}
b.DockerAuth = auth
// model-dir
b.ModelDir = strings.TrimSpace(b.ModelDir)
if b.ModelDir == "" {
b.ModelDir = "./"
}
// option-file/username/password/model/debug: nothing to deal with
// the unexported fields that users do not need to care about are as follows:
b.repository = DefaultBuilderRepository
b.tempDir, err = os.MkdirTemp("", HostTempDirPattern)
if err != nil && !os.IsExist(err) {
return errors.Wrap(err, "failed to create the host temporary dir")
}
dockerEp, err := os.CreateTemp("", HostDockerEntryPattern)
if err != nil && !os.IsExist(err) {
return errors.Wrap(err, "failed to create the docker entrypoint file")
}
err = dockerEp.Chmod(0777)
if err != nil {
return err
}
b.dockerEntrypoint = dockerEp.Name()
dockerEp.Close()
u, err := user.Current()
if err != nil {
return errors.Wrap(err, "failed to get the current user information")
}
b.uid, b.gid = u.Uid, u.Gid
b.containerConf = types.ContainerCreateConfig{
Name: "higress-wasm-go-builder",
Config: &container.Config{
Image: b.builderImageRef(),
Env: []string{
"GO111MODULE=on",
"GOPROXY=https://goproxy.cn,direct",
},
WorkingDir: ContainerWorkDir,
Entrypoint: []string{ContainerEntryFilePath},
},
HostConfig: &container.HostConfig{
NetworkMode: "host",
Mounts: []mount.Mount{
{ // input dir that includes the wasm plugin source: main.go ...
Type: mount.TypeBind,
Source: b.Input,
Target: ContainerWorkDir,
},
{ // temp dir that includes the wasm plugin metadata: spec.yaml and README.md ...
Type: mount.TypeBind,
Source: b.tempDir,
Target: ContainerTempDir,
},
{ // entrypoint
Type: mount.TypeBind,
Source: b.dockerEntrypoint,
Target: ContainerEntryFilePath,
},
},
},
}
if b.dockerCli == nil {
b.dockerCli, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return errors.Wrap(err, "failed to initialize the docker client")
}
}
if b.w == nil {
b.w = os.Stdout
}
b.sig = make(chan os.Signal, 1)
b.stop = make(chan struct{}, 1)
b.done = make(chan struct{}, 1)
signal.Notify(b.sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM,
syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGQUIT, syscall.SIGTSTP)
if b.Debugger == nil {
b.Debugger = utils.NewDefaultDebugger(b.Debug, b.w)
}
if b.YesOrNoPrinter == nil {
b.YesOrNoPrinter = utils.NewPrinter(b.w, utils.DefaultIdent, utils.DefaultYes, utils.DefaultNo)
}
return nil
}
func (b *Builder) parseOptions(v *viper.Viper, cmd *cobra.Command) error {
allOpt, err := option.ParseOptions(b.OptionFile, v, cmd.PersistentFlags())
if err != nil {
return err
}
b.BuildOptions = allOpt.Build
b.w = cmd.OutOrStdout()
return nil
}
func (b *Builder) finish() {
select {
case <-b.done:
default:
close(b.done)
}
}
func (b *Builder) waitForFinished() {
<-b.done
}
func (b *Builder) interrupt() {
select {
case <-b.stop:
default:
close(b.stop)
}
}
func (b *Builder) isInterrupted() bool {
if b.stop == nil {
return true
}
select {
case <-b.stop:
return true
default:
return false
}
}
// WithManualClean if set this option, then the temporary files and the container
// will not be cleaned up automatically, and you need to clean up manually
func (b *Builder) WithManualClean() {
b.manualClean = true
}
func (b *Builder) WithWriter(w io.Writer) {
b.w = w
}
// CleanupForError cleans up the temporary files and the container when an error occurs
func (b *Builder) CleanupForError() {
b.Cleanup()
b.removeOutputDest()
}
// Cleanup cleans up the temporary files and the container
func (b *Builder) Cleanup() {
b.removeTempDir()
b.removeDockerEntrypoint()
b.removeBuilderContainer()
b.closeDockerCli()
}
func (b *Builder) removeOutputDest() {
if b.BuildOptions.Output.Type == "files" {
b.Debugf("remove output destination %q\n", b.BuildOptions.Output.Dest)
os.RemoveAll(b.BuildOptions.Output.Dest)
}
}
func (b *Builder) removeTempDir() {
if b.tempDir != "" {
b.Debugf("remove temporary directory %q\n", b.tempDir)
os.RemoveAll(b.tempDir)
}
}
func (b *Builder) removeDockerEntrypoint() {
if b.dockerEntrypoint != "" {
b.Debugf("delete docker entrypoint %q\n", b.dockerEntrypoint)
os.Remove(b.dockerEntrypoint)
}
}
func (b *Builder) removeBuilderContainer() {
if b.containerID != "" {
err := b.dockerCli.ContainerRemove(context.TODO(), b.containerID, types.ContainerRemoveOptions{Force: true})
if err != nil {
b.Debugf("failed to remove container (%s): %s\n", b.containerConf.Name, b.containerID)
} else {
b.Debugf("remove container (%s): %s\n", b.containerConf.Name, b.containerID)
}
}
}
func (b *Builder) closeDockerCli() {
if b.dockerCli != nil {
b.Debugln("close the docker client")
b.dockerCli.Close()
}
}
func (b *Builder) builderImageRef() string {
return fmt.Sprintf("%s:go%s-tinygo%s-oras%s", b.repository, b.Builder.Go, b.Builder.TinyGo, b.Builder.Oras)
}
func (b *Builder) SpecYAMLPath() string {
return fmt.Sprintf("%s/spec.yaml", b.tempDir)
}
func (b *Builder) TempDir() string {
return b.tempDir
}
func (b *Builder) String() string {
by, err := json.MarshalIndent(b, "", " ")
if err != nil {
return ""
}
return string(by)
}

View File

@@ -0,0 +1,194 @@
// 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 build
import (
"os"
"text/template"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types"
)
const (
filesDockerEntrypoint = `#!/bin/bash
set -e
{{- if eq .Debug true }}
set -x
{{- end }}
go mod tidy
tinygo build -o {{ .BuildDestDir }}/plugin.wasm -scheduler=none -gc=custom -tags='custommalloc nottinygc_finalizer' -target=wasi {{ .BuildSrcDir }}
mv {{ .BuildDestDir }}/* {{ .Output }}/
chown -R {{ .UID }}:{{ .GID }} {{ .Output }}
`
imageDockerEntrypoint = `#!/bin/bash
set -e
{{- if eq .Debug true }}
set -x
{{- end }}
go mod tidy
tinygo build -o {{ .BuildDestDir }}/plugin.wasm -scheduler=none -gc=custom -tags='custommalloc nottinygc_finalizer' -target=wasi {{ .BuildSrcDir }}
cd {{ .BuildDestDir }}
tar czf plugin.tar.gz plugin.wasm
cmd="{{ .BasicCmd }}"
products=({{ .Products }})
for ((i=0; i<${#products[*]}; i=i+2)); do
f=${products[i]}
typ=${products[i+1]}
if [ -e ${f} ]; then
cmd="${cmd} ./${f}:${typ}"
fi
done
cmd="${cmd} ./plugin.tar.gz:{{ .MediaTypePlugin }}"
eval ${cmd}
`
)
type FilesTmplFields struct {
BuildSrcDir string
BuildDestDir string
Output string
UID, GID string
Debug bool
}
type ImageTmplFields struct {
BuildSrcDir string
BuildDestDir string
Output string
Username, Password string
BasicCmd string
Products string
MediaTypePlugin string
Debug bool
}
func genFilesDockerEntrypoint(ft *FilesTmplFields, target string) error {
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, 0777)
if err != nil {
return err
}
defer f.Close()
if err = template.Must(template.New("FilesDockerEntrypoint").Parse(filesDockerEntrypoint)).Execute(f, ft); err != nil {
return err
}
return nil
}
func genImageDockerEntrypoint(it *ImageTmplFields, target string) error {
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, 0777)
if err != nil {
return err
}
defer f.Close()
if err = template.Must(template.New("ImageDockerEntrypoint").Parse(imageDockerEntrypoint)).Execute(f, it); err != nil {
return err
}
return nil
}
const (
readme_zh_CN = `> 该插件用法文件根据源代码自动生成,请根据需求自行修改!
# 功能说明
{{ .Description }}
# 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- | -------- | -------- | -------- |
{{- range .ConfigEntries }}
| {{ .Name }} | {{ .Type }} | {{ .Requirement }} | {{ .Default }} | {{ .Description }} |
{{- end }}
# 配置示例
` + "```yaml" + `
{{ .Example }}
` + "```" + `
`
readme_en_US = `> THIS PLUGIN USAGE FILE IS AUTOMATICALLY GENERATED BASED ON THE SOURCE CODE. MODIFY IT AS REQUIRED!
# Description
{{ .Description }}
# Configuration
| Name | Type | Requirement | Default | Description |
| -------- | -------- | -------- | -------- | -------- |
{{- range .ConfigEntries }}
| {{ .Name }} | {{ .Type }} | {{ .Requirement }} | {{ .Default }} | {{ .Description }} |
{{- end }}
# Examples
` + "```yaml" + `
{{ .Example }}
` + "```" + `
`
)
func genMarkdownUsage(u *types.WasmUsage, dir string, suffix bool) error {
md, err := os.Create(i18n2MDTitle(u.I18nType, dir, suffix))
if err != nil {
return err
}
defer md.Close()
if err = template.Must(template.New("MD_Usage").Parse(i18n2MD(u.I18nType))).Execute(md, u); err != nil {
return err
}
return nil
}
func i18n2MD(i18n types.I18nType) string {
switch i18n {
case types.I18nEN_US:
return readme_en_US
case types.I18nZH_CN:
return readme_zh_CN
default:
return readme_zh_CN
}
}
func i18n2MDTitle(i18n types.I18nType, dir string, suffix bool) string {
var file string
if !suffix {
file = "README.md"
} else {
switch i18n {
case types.I18nEN_US:
file = "README_EN.md"
case types.I18nZH_CN:
file = "README_ZH.md"
default:
file = "README_ZH.md"
}
}
return dir + "/" + file
}

View File

@@ -0,0 +1,30 @@
// 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 config
import "github.com/spf13/cobra"
func NewCommand() *cobra.Command {
configCmd := &cobra.Command{
Use: "config",
Aliases: []string{"conf", "cnf"},
Short: "Configure the WasmPlugin manifest",
}
configCmd.AddCommand(newCreateCommand())
configCmd.AddCommand(newEditCommand())
return configCmd
}

View File

@@ -0,0 +1,76 @@
// 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 config
import (
"fmt"
"io"
"os"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
func newCreateCommand() *cobra.Command {
var target string
createCmd := &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create the WASM plugin configuration template file",
Example: ` hgctl plugin config create`,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(create(cmd.OutOrStdout(), target))
},
}
createCmd.PersistentFlags().StringVarP(&target, "target", "t", "./", "Directory where the configuration is generated")
return createCmd
}
func create(w io.Writer, target string) error {
target, err := utils.GetAbsolutePath(target)
if err != nil {
return errors.Wrap(err, "invalid target path")
}
if err = os.MkdirAll(target, 0755); err != nil {
return err
}
if err = GenPluginConfYAML(configHelpTmpl, target); err != nil {
return errors.Wrap(err, "failed to create configuration template")
}
fmt.Fprintf(w, "Created configuration template %q\n", fmt.Sprintf("%s/%s", target, "plugin-conf.yaml"))
return nil
}
var configHelpTmpl = &PluginConf{
Name: "Plugin Name",
Namespace: "higress-system",
Title: "Display Name",
Description: "Plugin Description",
IconUrl: "Plugin Icon",
Version: "0.1.0",
Category: "auth | security | protocol | flow-control | flow-monitor | custom",
Phase: "UNSPECIFIED_PHASE | AUTHN | AUTHZ | STATS",
Priority: 0,
Config: " Plugin Configuration",
Url: "Plugin Image URL",
}

View File

@@ -0,0 +1,143 @@
// 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 config
import (
"bytes"
"context"
"fmt"
"io"
"os"
k8s "github.com/alibaba/higress/pkg/cmd/hgctl/kubernetes"
"github.com/alibaba/higress/pkg/cmd/options"
"github.com/pkg/errors"
"github.com/spf13/cobra"
k8serr "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/cli-runtime/pkg/printers"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/cmd/util/editor"
)
func newEditCommand() *cobra.Command {
var name string
editCmd := &cobra.Command{
Use: "edit",
Aliases: []string{"e"},
Short: "Edit the installed WASM plugin configuration",
Example: ` # Edit the installed WASM plugin 'request-block'
hgctl plugin config edit -p request-block
`,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(edit(cmd.OutOrStdout(), name))
},
}
flags := editCmd.PersistentFlags()
options.AddKubeConfigFlags(flags)
k8s.AddHigressNamespaceFlags(flags)
flags.StringVarP(&name, "name", "p", "", "Name of the WASM plugin that needs to be edited")
return editCmd
}
func edit(w io.Writer, name string) error {
// TODO(WeixinX): Use WasmPlugin Object type instead of Unstructured
dynCli, err := k8s.NewDynamicClient(options.DefaultConfigFlags.ToRawKubeConfigLoader())
if err != nil {
return errors.Wrap(err, "failed to build kubernetes dynamic client")
}
cli := k8s.NewWasmPluginClient(dynCli)
originalObj, err := cli.Get(context.TODO(), name)
if err != nil {
if k8serr.IsNotFound(err) {
return errors.Errorf("wasm plugin %q is not found", fmt.Sprintf("%s/%s", k8s.HigressNamespace, name))
}
return errors.Wrapf(err, "failed to get wasm plugin %q", fmt.Sprintf("%s/%s", k8s.HigressNamespace, name))
}
originalObj.SetGroupVersionKind(k8s.WasmPluginGVK)
originalObj.SetManagedFields(nil) // TODO(WeixinX): Managed Fields should be written back
buf := &bytes.Buffer{}
var wObj io.Writer = buf
printer := printers.YAMLPrinter{}
if err = printer.PrintObj(originalObj.DeepCopyObject(), wObj); err != nil {
return err
}
original := buf.Bytes()
e := editor.NewDefaultEditor(editorEnvs())
edited, file, err := e.LaunchTempFile("higress-wasm-edit-", ".yaml", buf)
if err != nil {
return errors.Wrap(err, "failed to launch editor")
}
defer os.Remove(file)
if bytes.Equal(cmdutil.StripComments(original), cmdutil.StripComments(edited)) { // no change
fmt.Fprintf(w, "edit %q canceled, no change\n",
fmt.Sprintf("%s/%s", originalObj.GetNamespace(), originalObj.GetName()))
return nil
}
var editedObj unstructured.Unstructured
eBuf := bytes.NewReader(edited)
dc := yaml.NewYAMLOrJSONDecoder(eBuf, 4096)
if err = dc.Decode(&editedObj); err != nil {
return err
}
if !keepSameMeta(&editedObj, originalObj) {
fmt.Fprintln(w, "Warning: ensure that the apiVersion, kind, namespace, and name are the same as the original and are automatically corrected")
}
ret, err := cli.Update(context.TODO(), &editedObj)
if err != nil {
return errors.Wrapf(err, "failed to update wasm plugin %q",
fmt.Sprintf("%s/%s", originalObj.GetNamespace(), originalObj.GetName()))
}
fmt.Fprintf(w, "Edited wasm plugin %q\n", fmt.Sprintf("%s/%s", ret.GetNamespace(), ret.GetName()))
return nil
}
func editorEnvs() []string {
return []string{
"KUBE_EDITOR",
"EDITOR",
}
}
// to avoid changing the apiVersion, kind, namespace and name, keep them the same as the original
func keepSameMeta(edited, original *unstructured.Unstructured) bool {
same := true
if edited.GroupVersionKind().String() != original.GroupVersionKind().String() {
edited.SetGroupVersionKind(original.GroupVersionKind())
same = false
}
if edited.GetNamespace() != original.GetNamespace() {
edited.SetNamespace(original.GetNamespace())
same = false
}
if edited.GetName() != original.GetName() {
edited.SetName(original.GetName())
same = false
}
return same
}

View File

@@ -0,0 +1,160 @@
// 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 config
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/template"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"gopkg.in/yaml.v3"
)
// TODO(WeixinX): Use 'hgctl plugin push' command to fill the image url automatically
const pluginConfYAML = `# File generated by hgctl. Modify as required.
# See: https://higress.io/zh-cn/docs/plugins/intro
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: {{ .Name }}
namespace: {{ .Namespace }}
annotations:
higress.io/wasm-plugin-title: {{ .Title }}
higress.io/wasm-plugin-description: {{ .Description }}
higress.io/wasm-plugin-icon: {{ .IconUrl }}
labels:
higress.io/wasm-plugin-name: {{ .Name }}
higress.io/wasm-plugin-category: {{ .Category }}
higress.io/wasm-plugin-version: {{ .Version }}
higress.io/resource-definer: higress
higress.io/wasm-plugin-built-in: "false"
spec:
phase: {{ .Phase }}
priority: {{ .Priority }}
{{ .Config }}
# Please fill the image url in according to your needs
url: {{ .Url }}
`
type PluginConf struct {
Name string
Namespace string
Title string
Description string
IconUrl string
Version string
Category string
Phase string
Priority int64
Config string
Url string
}
func (pc *PluginConf) String() string {
b, err := json.MarshalIndent(pc, "", " ")
if err != nil {
return ""
}
return string(b)
}
// GenPluginConfYAML generates plugin-conf.yaml based on the template
func GenPluginConfYAML(p *PluginConf, dir string) error {
path := fmt.Sprintf("%s/plugin-conf.yaml", dir)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if err = template.Must(template.New("PluginConfYAML").Parse(pluginConfYAML)).Execute(f, p); err != nil {
return err
}
return nil
}
// ExtractPluginConfFrom extracts the params of plugin-conf.yaml from spec.yaml.
// input params `config`, `url` are only used to implement the command `hgctl plugin install -g <go-project>`
func ExtractPluginConfFrom(spec *types.WasmPluginMeta, config, url string) (*PluginConf, error) {
if config == "" {
// by default, Example from spec.yaml is used as the defaultConfig for the wasm plugin
var obj map[string]interface{}
example := spec.GetConfigExample()
if err := yaml.Unmarshal([]byte(example), &obj); err != nil {
return nil, err
}
conf := struct {
DefaultConfig map[string]interface{} `yaml:"defaultConfig,omitempty"`
}{DefaultConfig: obj}
b, err := utils.MarshalYamlWithIndent(conf, 2)
if err != nil {
return nil, err
}
config = string(b)
}
pc := &PluginConf{
Name: spec.Info.Name,
Namespace: "higress-system",
Title: spec.Info.Title,
Description: spec.Info.Description,
IconUrl: spec.Info.IconUrl,
Version: spec.Info.Version,
Category: string(spec.Info.Category),
Phase: string(spec.Spec.Phase),
Priority: spec.Spec.Priority,
Config: utils.AddIndent(config, strings.Repeat(" ", 2)),
Url: url,
}
pc.withDefaultValue()
return pc, nil
}
func (pc *PluginConf) withDefaultValue() {
if pc.Name == "" {
pc.Name = "Unnamed"
}
if pc.Namespace == "" {
pc.Namespace = "higress-system"
}
if pc.Title == "" {
pc.Title = "Untitled"
}
if pc.Description == "" {
pc.Description = "No description"
}
if pc.IconUrl == "" {
pc.IconUrl = types.Category2IconUrl(types.Category(pc.Category))
}
if pc.Version == "" {
pc.Version = "0.1.0"
}
if pc.Category == "" {
pc.Category = string(types.CategoryDefault)
}
if pc.Phase == "" {
pc.Phase = string(types.PhaseDefault)
}
}

View File

@@ -0,0 +1,92 @@
// 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 plugininit
import (
"fmt"
"io"
"os"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/pkg/errors"
"github.com/spf13/cobra"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
func NewCommand() *cobra.Command {
var target string
initCmd := &cobra.Command{
Use: "init",
Aliases: []string{"ini", "i"},
Short: "Initialize a Golang WASM plugin project",
Example: ` hgctl plugin init`,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(runInit(cmd.OutOrStdout(), target))
},
}
initCmd.PersistentFlags().StringVarP(&target, "target", "t", "./", "Directory where the project is initialized")
return initCmd
}
func runInit(w io.Writer, target string) (err error) {
ans := answer{}
err = utils.Ask(questions, &ans)
if err != nil {
if errors.Is(err, terminal.InterruptErr) {
fmt.Fprintf(w, "Interrupted\n")
return nil
}
return errors.Wrap(err, "failed to initialize the project")
}
target, err = utils.GetAbsolutePath(target)
if err != nil {
return errors.Wrap(err, "invalid target directory")
}
dir := fmt.Sprintf("%s/%s", target, ans.Name)
err = os.MkdirAll(dir, 0755)
defer func() {
if err != nil {
os.RemoveAll(dir)
err = errors.Wrap(err, "failed to initialize the project")
}
}()
if err != nil {
return
}
if err = genGoMain(&ans, dir); err != nil {
return errors.Wrap(err, "failed to create main.go")
}
if err = genGoMod(&ans, dir); err != nil {
return errors.Wrap(err, "failed to create go.mod")
}
if err = genGitIgnore(dir); err != nil {
return errors.Wrap(err, "failed to create .gitignore")
}
if err = option.GenOptionYAML(dir); err != nil {
return errors.Wrap(err, "failed to create option.yaml")
}
fmt.Fprintf(w, "Initialized the project in %q\n", dir)
return nil
}

View File

@@ -0,0 +1,296 @@
// 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 plugininit
import (
"fmt"
"os"
"text/template"
"github.com/AlecAivazis/survey/v2"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types"
)
const (
goMain = `// File generated by hgctl. Modify as required.
// See: https://higress.io/zh-cn/docs/user/wasm-go#2-%E7%BC%96%E5%86%99-maingo-%E6%96%87%E4%BB%B6
package main
import (
"github.com/tidwall/gjson"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
func main() {
wrapper.SetCtx(
"{{ .Name }}",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
// @Name {{ .Name }}
// @Category {{ .Category }}
// @Phase {{ .Phase }}
// @Priority {{ .Priority }}
// @Title {{ .I18nType }} {{ .Title }}
// @Description {{ .I18nType }} {{ .Description }}
// @IconUrl {{ .IconUrl }}
// @Version {{ .Version }}
//
// @Contact.name {{ .ContactName }}
// @Contact.url {{ .ContactUrl }}
// @Contact.email {{ .ContactEmail }}
//
// @Example
// firstField: hello
// secondField: world
// @End
//
type PluginConfig struct {
// @Title 第一个字段,注解格式为 @Title [语言] [标题],语言缺省值为 en-US
// @Description 字符串的前半部分,注解格式为 @Description [语言] [描述],语言缺省值为 en-US
firstField string ` + "`required:\"true\"`" + `
// @Title en-US Second Field, annotation format is @Title [language] [title], language defaults to en-US
// @Description en-US The second half of the string, annotation format is @Description [language] [description], language defaults to en-US
secondField string ` + "`required:\"true\"`" + `
}
func parseConfig(json gjson.Result, config *PluginConfig, log wrapper.Log) error {
config.firstField = json.Get("firstField").String()
config.secondField = json.Get("secondField").String()
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action {
err := proxywasm.AddHttpRequestHeader(config.firstField, config.secondField)
if err != nil {
log.Critical("failed to set request header")
}
return types.ActionContinue
}
`
goMod = `// File generated by hgctl. Modify as required.
module {{ .Name }}
go 1.19
require (
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20231019123123-86b223bc75f1
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0
github.com/tidwall/gjson v1.14.3
)
`
gitIgnore = `# File generated by hgctl. Modify as required.
*
!/.gitignore
!*.go
!go.sum
!go.mod
!LICENSE
!*.md
!*.yaml
!*.yml
!*/
/out
/test
`
)
func genGoMain(ans *answer, dir string) error {
path := fmt.Sprintf("%s/main.go", dir)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if err = template.Must(template.New("GoMain").Parse(goMain)).Execute(f, ans); err != nil {
return err
}
return nil
}
func genGoMod(ans *answer, dir string) error {
path := fmt.Sprintf("%s/go.mod", dir)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if err = template.Must(template.New("GoMod").Parse(goMod)).Execute(f, ans); err != nil {
return err
}
return nil
}
func genGitIgnore(dir string) error {
path := fmt.Sprintf("%s/.gitignore", dir)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err = f.WriteString(gitIgnore); err != nil {
return err
}
return nil
}
// obtain parameters through command line interaction
type answer struct {
Name string
Category string
Phase string
Priority int64
I18nType string
Title string
Description string
IconUrl string
Version string
ContactName string
ContactUrl string
ContactEmail string
}
var questions = []*survey.Question{
{
Name: "Name",
Prompt: &survey.Input{
Message: "Plugin name:",
Default: "hello-world",
},
Validate: survey.Required,
},
{
Name: "Category",
Prompt: &survey.Select{
Message: "Choose a plugin category:",
Options: []string{
string(types.CategoryCustom),
string(types.CategoryAuth),
string(types.CategorySecurity),
string(types.CategoryProtocol),
string(types.CategoryFlowControl),
string(types.CategoryFlowMonitor),
},
Default: string(types.CategoryCustom),
},
Validate: survey.Required,
},
{
Name: "Phase",
Prompt: &survey.Select{
Message: "Choose a execution phase:",
Options: []string{
string(types.PhaseUnspecified),
string(types.PhaseAuthn),
string(types.PhaseAuthz),
string(types.PhaseStats),
},
Default: string(types.PhaseUnspecified),
},
Validate: survey.Required,
},
{
Name: "Priority",
Prompt: &survey.Input{
Message: "Execution priority:",
Default: "0",
},
Validate: survey.Required,
},
{
Name: "I18nType",
Prompt: &survey.Select{
Message: "Choose a language:",
Options: []string{
string(types.I18nEN_US),
string(types.I18nZH_CN),
},
Default: string(types.I18nDefault),
},
Validate: survey.Required,
},
{
Name: "Title",
Prompt: &survey.Input{
Message: "Display name in the plugin market:",
Default: "Hello World",
},
Validate: survey.Required,
},
{
Name: "Description",
Prompt: &survey.Input{
Message: "Description of the plugin functionality:",
Default: "This is a demo plugin",
},
},
{
Name: "IconUrl",
Prompt: &survey.Input{
Message: "Display icon in the plugin market:",
Default: "",
},
},
{
Name: "Version",
Prompt: &survey.Input{
Message: "Plugin version:",
Default: "0.1.0",
},
Validate: survey.Required,
},
{
Name: "ContactName",
Prompt: &survey.Input{
Message: "Name of developer:",
Default: "",
},
},
{
Name: "ContactUrl",
Prompt: &survey.Input{
Message: "Homepage of developer:",
Default: "",
},
},
{
Name: "ContactEmail",
Prompt: &survey.Input{
Message: "Email of developer:",
Default: "",
},
},
}

View File

@@ -0,0 +1,744 @@
// 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/pkg/cmd/hgctl/plugin/types"
"github.com/alibaba/higress/pkg/cmd/hgctl/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
}

View File

@@ -0,0 +1,383 @@
// 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/pkg/cmd/hgctl/kubernetes"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/build"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/config"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types"
"github.com/alibaba/higress/pkg/cmd/hgctl/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))
}

View File

@@ -0,0 +1,78 @@
// 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 ls
import (
"context"
"fmt"
"io"
"time"
k8s "github.com/alibaba/higress/pkg/cmd/hgctl/kubernetes"
"github.com/alibaba/higress/pkg/cmd/options"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/util/duration"
"k8s.io/cli-runtime/pkg/printers"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
func NewCommand() *cobra.Command {
lsCmd := &cobra.Command{
Use: "ls",
Aliases: []string{"l"},
Short: "List all installed WASM plugins",
Example: ` hgctl plugin ls`,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(runLs(cmd.OutOrStdout()))
},
}
flags := lsCmd.PersistentFlags()
options.AddKubeConfigFlags(flags)
k8s.AddHigressNamespaceFlags(flags)
return lsCmd
}
func runLs(w io.Writer) error {
dynCli, err := k8s.NewDynamicClient(options.DefaultConfigFlags.ToRawKubeConfigLoader())
if err != nil {
return errors.Wrap(err, "failed to build kubernetes client")
}
cli := k8s.NewWasmPluginClient(dynCli)
list, err := cli.List(context.TODO())
if err != nil {
return errors.Wrap(err, "failed to list all wasm plugins")
}
printer := printers.GetNewTabWriter(w)
now := time.Now()
fmt.Fprintf(printer, "NAME\tAGE\n")
for _, item := range list.Items {
fmt.Fprintf(printer, "%s\t%s\n", item.GetName(), getAge(now, item.GetCreationTimestamp().Time))
}
if err = printer.Flush(); err != nil {
return errors.Wrap(err, "failed to flush output")
}
return nil
}
func getAge(now time.Time, create time.Time) string {
return duration.ShortHumanDuration(now.Sub(create))
}

View File

@@ -0,0 +1,96 @@
// 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 option
import (
"os"
"github.com/pkg/errors"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
type Option struct {
Version string `json:"version" yaml:"version" mapstructure:"version"`
Build BuildOptions `json:"build" yaml:"build" mapstructure:"build"`
Test TestOptions `json:"test" yaml:"test" mapstructure:"test"`
Install InstallOptions `json:"install" yaml:"install" mapstructure:"install"`
}
type BuildOptions struct {
Builder BuilderVersion `json:"builder" yaml:"builder" mapstructure:"builder"`
Input string `json:"input" yaml:"input" mapstructure:"input"`
Output Output `json:"output" yaml:"output" mapstructure:"output"`
DockerAuth string `json:"docker-auth" yaml:"docker-auth" mapstructure:"docker-auth"`
ModelDir string `json:"model-dir" yaml:"model-dir" mapstructure:"model-dir"`
Model string `json:"model" yaml:"model" mapstructure:"model"`
Debug bool `json:"debug" yaml:"debug" mapstructure:"debug"`
}
type TestOptions struct {
Name string `json:"name" yaml:"name" mapstructure:"name"`
FromPath string `json:"from-path" yaml:"from-path" mapstructure:"from-path"`
TestPath string `json:"test-path" yaml:"test-path" mapstructure:"test-path"`
ComposeFile string `json:"compose-file" yaml:"compose-file" mapstructure:"compose-file"`
Detach bool `json:"detach" yaml:"detach" mapstructure:"detach"`
}
type InstallOptions struct {
Namespace string `json:"namespace" yaml:"namespace" mapstructure:"namespace"`
SpecYaml string `json:"spec-yaml" yaml:"spec-yaml" mapstructure:"spec-yaml"`
FromYaml string `json:"from-yaml" yaml:"from-yaml" mapstructure:"from-yaml"`
FromGoSrc string `json:"from-go-src" yaml:"from-go-src" mapstructure:"from-go-src"`
Debug bool `json:"debug" yaml:"debug" mapstructure:"debug"`
}
type BuilderVersion struct {
Go string `json:"go" yaml:"go" mpastructure:"go"`
TinyGo string `json:"tinygo" yaml:"tinygo" mapstructure:"tinygo"`
Oras string `json:"oras" yaml:"oras" mapstructure:"oras"`
}
type Output struct {
Type string `json:"type" yaml:"type" mapstructure:"type"`
Dest string `json:"dest" yaml:"dest" mapstructure:"dest"`
}
// ParseOptions reads `option.yaml` and parses it into Option struct
func ParseOptions(optionFile string, v *viper.Viper, flags *pflag.FlagSet) (*Option, error) {
_, err := os.Stat(optionFile)
if err != nil {
// `option-file` is explicitly specified, but the given file does not exist
if errors.Is(err, os.ErrNotExist) && flags.Changed("option-file") {
return nil, errors.Errorf("option file does not exist: %q", optionFile)
}
} else {
v.SetConfigFile(optionFile)
if err = v.ReadInConfig(); err != nil {
return nil, errors.Wrapf(err, "failed to read option file %q", optionFile)
}
}
var opt Option
if err = v.Unmarshal(&opt); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal option file %q", optionFile)
}
return &opt, nil
}
// AddOptionFileFlag adds `option-file` flag
func AddOptionFileFlag(optionFile *string, flags *pflag.FlagSet) {
flags.StringVarP(optionFile, "option-file", "f", "./option.yaml",
"Option file for build, test and install")
}

View File

@@ -0,0 +1,89 @@
// 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 option
import (
"fmt"
"os"
)
const optionYAML = `# File generated by hgctl. Modify as required.
version: 1.0.0
build:
# The official builder image version
builder:
go: 1.19
tinygo: 0.28.1
oras: 1.0.0
# The WASM plugin project directory
input: ./
# The output of the build products
output:
# Choose between 'files' and 'image'
type: files
# Destination address: when type=files, specify the local directory path, e.g., './out' or
# type=image, specify the remote docker repository, e.g., 'docker.io/<your_username>/<your_image>'
dest: ./out
# The authentication configuration for pushing image to the docker repository
docker-auth: ~/.docker/config.json
# The directory for the WASM plugin configuration structure
model-dir: ./
# The WASM plugin configuration structure name
model: PluginConfig
# Enable debug mode
debug: false
test:
# Test environment name, that is a docker compose project name
name: wasm-test
# The output path to build products, that is the source of test configuration parameters
from-path: ./out
# The test configuration source
test-path: ./test
# Docker compose configuration, which is empty, looks for the following files from 'test-path':
# compose.yaml, compose.yml, docker-compose.yml, docker-compose.yaml
compose-file:
# Detached mode: Run containers in the background
detach: false
install:
# The namespace of the installation
namespace: higress-system
# Use to validate WASM plugin configuration when install by yaml
spec-yaml: ./out/spec.yaml
# Installation source. Choose between 'from-yaml' and 'from-go-project'
from-yaml: ./test/plugin-conf.yaml
# If 'from-go-src' is non-empty, the output type of the build option must be 'image'
from-go-src:
# Enable debug mode
debug: false
`
func GenOptionYAML(dir string) error {
path := fmt.Sprintf("%s/option.yaml", dir)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err = f.WriteString(optionYAML); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,45 @@
// 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 plugin
import (
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/build"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/config"
plugininit "github.com/alibaba/higress/pkg/cmd/hgctl/plugin/init"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/install"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/ls"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/test"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/uninstall"
"github.com/spf13/cobra"
)
func NewCommand() *cobra.Command {
pluginCommand := &cobra.Command{
Use: "plugin",
Aliases: []string{"plg", "p"},
Short: "For the Golang WASM plugin",
}
pluginCommand.AddCommand(build.NewCommand())
pluginCommand.AddCommand(install.NewCommand())
pluginCommand.AddCommand(uninstall.NewCommand())
pluginCommand.AddCommand(ls.NewCommand())
pluginCommand.AddCommand(test.NewCommand())
pluginCommand.AddCommand(config.NewCommand())
pluginCommand.AddCommand(plugininit.NewCommand())
return pluginCommand
}

View File

@@ -0,0 +1,108 @@
// 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 test
import (
"context"
"fmt"
"io"
"os"
"github.com/alibaba/higress/pkg/cmd/hgctl/docker"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
type cleaner struct {
optionFile string
option.TestOptions
w io.Writer
}
func newCleanCommand() *cobra.Command {
var c cleaner
v := viper.New()
cleanCmd := &cobra.Command{
Use: "clean",
Aliases: []string{"cl"},
Short: "Clean the test environment, that is remove the source of test configuration",
Example: ` hgctl plugin test clean`,
PreRun: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(c.config(v, cmd))
},
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(c.clean())
},
}
flags := cleanCmd.PersistentFlags()
option.AddOptionFileFlag(&c.optionFile, flags)
v.BindPFlags(flags)
flags.StringP("name", "p", "wasm-test", "Test environment name")
v.BindPFlag("test.name", flags.Lookup("name"))
v.SetDefault("test.name", "wasm-test")
// TODO(WeixinX): Obtain the test configuration source directory based on the test environment name (hgctl plugin test ls)
flags.StringP("test-path", "t", "./test", "Test configuration source")
v.BindPFlag("test.test-path", flags.Lookup("test-path"))
v.SetDefault("test.test-path", "./test")
return cleanCmd
}
func (c *cleaner) config(v *viper.Viper, cmd *cobra.Command) error {
allOpt, err := option.ParseOptions(c.optionFile, v, cmd.PersistentFlags())
if err != nil {
return err
}
c.TestOptions = allOpt.Test
c.w = cmd.OutOrStdout()
return nil
}
func (c *cleaner) clean() error {
cli, err := docker.NewCompose(c.w)
if err != nil {
return errors.Wrap(err, "failed to build the docker compose client")
}
err = cli.Down(context.TODO(), c.Name)
if err != nil {
return errors.Wrapf(err, "failed to stop the test environment %q", c.Name)
}
fmt.Fprintf(c.w, "Stopped the test environment %q\n", c.Name)
source, err := utils.GetAbsolutePath(c.TestPath)
if err != nil {
return errors.Wrapf(err, "invalid test configuration source %q", c.TestPath)
}
err = os.RemoveAll(source)
if err != nil {
return errors.Wrapf(err, "failed to remove the test configuration source %q", source)
}
fmt.Fprintf(c.w, "Removed the source %q\n", source)
return nil
}

View File

@@ -0,0 +1,175 @@
// 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 test
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/config"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
type creator struct {
optionFile string
option.TestOptions
w io.Writer
}
func newCreateCommand() *cobra.Command {
var c creator
v := viper.New()
createCmd := &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create the test environment",
Example: ` # If the option.yaml file exists in the current path, do the following:
hgctl plugin test create
# Explicitly specify the source of the parameters (directory of the build
products) and the directory where the test configuration files is stored
hgctl plugin test create -d ./out -t ./test
`,
PreRun: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(c.config(v, cmd))
},
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(c.create())
},
}
flags := createCmd.PersistentFlags()
option.AddOptionFileFlag(&c.optionFile, flags)
v.BindPFlags(flags)
flags.StringP("from-path", "d", "./out", "Path of storing the build products")
v.BindPFlag("test.from-path", flags.Lookup("from-path"))
v.SetDefault("test.from-path", "./out")
flags.StringP("test-path", "t", "./test", "Path for storing the test configuration")
v.BindPFlag("test.test-path", flags.Lookup("test-path"))
v.SetDefault("test.test-path", "./test")
return createCmd
}
func (c *creator) config(v *viper.Viper, cmd *cobra.Command) error {
allOpt, err := option.ParseOptions(c.optionFile, v, cmd.PersistentFlags())
if err != nil {
return err
}
c.TestOptions = allOpt.Test
c.w = cmd.OutOrStdout()
return nil
}
func (c *creator) create() (err error) {
source, err := utils.GetAbsolutePath(c.FromPath)
if err != nil {
return errors.Wrapf(err, "invalid build products path %q", c.FromPath)
}
c.FromPath = source
target, err := utils.GetAbsolutePath(c.TestPath)
if err != nil {
return errors.Wrapf(err, "invalid test path %q", c.TestPath)
}
c.TestPath = target
fields := testTmplFields{}
// 1. extract the parameters from spec.yaml and convert them to PluginConf
path := fmt.Sprintf("%s/spec.yaml", c.FromPath)
spec, err := types.ParseSpecYAML(path)
if err != nil {
return errors.Wrapf(err, "failed to parse %s", path)
}
fields.PluginConf, err = config.ExtractPluginConfFrom(spec, "", "")
if err != nil {
return errors.Wrapf(err, "failed to get the parameters of plugin-conf.yaml from %s", path)
}
// 2. get DockerCompose instance
fields.DockerCompose = &DockerCompose{
TestPath: c.TestPath,
ProductPath: c.FromPath,
}
// 3. get Envoy instance
var obj interface{}
conf := spec.GetConfigExample()
err = yaml.Unmarshal([]byte(conf), &obj)
if err != nil {
return errors.Wrap(err, "failed to get the example of wasm plugin")
}
b, err := json.MarshalIndent(obj, "", strings.Repeat(" ", 2))
if err != nil {
return errors.Wrap(err, "failed to marshal example to json")
}
jsExample := utils.AddIndent(string(b), strings.Repeat(" ", 30))
fields.Envoy = &Envoy{JSONExample: jsExample}
// 4. generate corresponding test files
if err = os.MkdirAll(target, 0755); err != nil {
return errors.Wrap(err, "failed to create the test environment")
}
if err = c.genTestConfFiles(fields); err != nil {
return errors.Wrap(err, "failed to create the test environment")
}
fmt.Fprintf(c.w, "Created the test environment in %q\n", target)
return nil
}
type testTmplFields struct {
PluginConf *config.PluginConf // for plugin-conf.yaml
DockerCompose *DockerCompose // for docker-compose.yaml
Envoy *Envoy // for envoy.yaml
}
func (c *creator) genTestConfFiles(fields testTmplFields) (err error) {
if err = config.GenPluginConfYAML(fields.PluginConf, c.TestPath); err != nil {
return err
}
if err = genDockerComposeYAML(fields.DockerCompose, c.TestPath); err != nil {
return err
}
if err = genEnvoyYAML(fields.Envoy, c.TestPath); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,64 @@
// 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 test
import (
"context"
"fmt"
"io"
"github.com/alibaba/higress/pkg/cmd/hgctl/docker"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"k8s.io/cli-runtime/pkg/printers"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
func newLsCommand() *cobra.Command {
lsCmd := &cobra.Command{
Use: "ls",
Aliases: []string{"l"},
Short: "List all test environments",
Example: ` hgctl plugin test ls`,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(runLs(cmd.OutOrStdout()))
},
}
return lsCmd
}
func runLs(w io.Writer) error {
cli, err := docker.NewCompose(w)
if err != nil {
return errors.Wrap(err, "failed to build the docker compose client")
}
list, err := cli.List(context.TODO())
if err != nil {
return errors.Wrap(err, "failed to list all test environments")
}
printer := printers.GetNewTabWriter(w)
// fmt.Fprintf(printer, "NAME\tSTATUS\tCONFIG FILES\n") // compose v2.3.0+
fmt.Fprintf(printer, "NAME\tSTATUS\n")
for _, stack := range list {
fmt.Fprintf(printer, "%s\t%s\n", stack.Name, stack.Status)
}
printer.Flush()
return nil
}

View File

@@ -0,0 +1,115 @@
// 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 test
import (
"context"
"fmt"
"io"
"github.com/alibaba/higress/pkg/cmd/hgctl/docker"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
// TODO(WeixinX): If no test environment exists, create one first and then start
type starter struct {
optionFile string
option.TestOptions
w io.Writer
}
func newStartCommand() *cobra.Command {
var s starter
v := viper.New()
startCmd := &cobra.Command{
Use: "start",
Aliases: []string{"s"},
Short: "Start the test environment",
Example: ` # If the option.yaml file exists in the current path, do the following:
hgctl plugin test start
# Run containers in the background with the option --detach(-d)
hgctl plugin test start -d
`,
PreRun: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(s.config(v, cmd))
},
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(s.start())
},
}
flags := startCmd.PersistentFlags()
option.AddOptionFileFlag(&s.optionFile, flags)
v.BindPFlags(flags)
flags.StringP("name", "p", "wasm-test", "Test environment name")
v.BindPFlag("test.name", flags.Lookup("name"))
v.SetDefault("test.name", "wasm-test")
flags.StringP("test-path", "t", "./test", "Test configuration source")
v.BindPFlag("test.test-path", flags.Lookup("test-path"))
v.SetDefault("test.test-path", "./test")
flags.StringP("compose-file", "c", "", "Docker compose configuration file")
v.BindPFlag("test.compose-file", flags.Lookup("compose-file"))
v.SetDefault("test.compose-file", "")
flags.BoolP("detach", "d", false, "Detached mode: Run containers in the background")
v.BindPFlag("test.detach", flags.Lookup("detach"))
v.SetDefault("test.detach", false)
return startCmd
}
func (s *starter) config(v *viper.Viper, cmd *cobra.Command) error {
allOpt, err := option.ParseOptions(s.optionFile, v, cmd.PersistentFlags())
if err != nil {
return err
}
s.TestOptions = allOpt.Test
s.w = cmd.OutOrStdout()
return nil
}
func (s *starter) start() error {
cli, err := docker.NewCompose(s.w)
if err != nil {
return errors.Wrap(err, "failed to build the docker compose client")
}
var configs []string
if s.ComposeFile != "" {
configs = []string{s.ComposeFile}
}
err = cli.Up(context.TODO(), s.Name, configs, s.TestPath, s.Detach)
if err != nil {
return errors.Wrap(err, "failed to start the test environment")
}
fmt.Fprintf(s.w, "Started the test environment %q\n", s.Name)
return nil
}

View File

@@ -0,0 +1,95 @@
// 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 test
import (
"context"
"fmt"
"io"
"github.com/alibaba/higress/pkg/cmd/hgctl/docker"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/option"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
type stopper struct {
optionFile string
option.TestOptions
w io.Writer
}
func newStopCommand() *cobra.Command {
var s stopper
v := viper.New()
stopCmd := &cobra.Command{
Use: "stop",
Aliases: []string{"st"},
Short: "Stop the test environment",
Example: ` # Stop responding to the compose containers with the option --name(-p)
hgctl plugin test stop -p wasm-test
`,
PreRun: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(s.config(v, cmd))
},
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(s.stop())
},
}
flags := stopCmd.PersistentFlags()
option.AddOptionFileFlag(&s.optionFile, flags)
v.BindPFlags(flags)
flags.StringP("name", "p", "wasm-test", "Test environment name")
v.BindPFlag("test.name", flags.Lookup("name"))
v.SetDefault("test.name", "wasm-test")
return stopCmd
}
func (s *stopper) config(v *viper.Viper, cmd *cobra.Command) error {
allOpt, err := option.ParseOptions(s.optionFile, v, cmd.PersistentFlags())
if err != nil {
return err
}
s.TestOptions = allOpt.Test
s.w = cmd.OutOrStdout()
return nil
}
func (s *stopper) stop() error {
cli, err := docker.NewCompose(s.w)
if err != nil {
return errors.Wrap(err, "failed to build the docker compose client")
}
err = cli.Down(context.TODO(), s.Name)
if err != nil {
return errors.Wrapf(err, "failed to stop the test environment %q", s.Name)
}
fmt.Fprintf(s.w, "Stopped the test environment %q\n", s.Name)
return nil
}

View File

@@ -0,0 +1,167 @@
// 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 test
import (
"fmt"
"os"
"text/template"
)
const (
dockerComposeYAML = `# File generated by hgctl. Modify as required.
version: '3.7'
services:
envoy:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/envoy:1.20
command: envoy -c /etc/envoy/envoy.yaml --component-log-level wasm:debug
depends_on:
- httpbin
networks:
- wasmtest
ports:
- "10000:10000"
volumes:
- {{ .TestPath }}/envoy.yaml:/etc/envoy/envoy.yaml
- {{ .ProductPath }}/plugin.wasm:/etc/envoy/plugin.wasm
httpbin:
image: kennethreitz/httpbin:latest
networks:
- wasmtest
ports:
- "12345:80"
networks:
wasmtest: {}
`
envoyYAML = `# File generated by hgctl. Modify as required.
admin:
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
scheme_header_transformation:
scheme_to_overwrite: https
stat_prefix: ingress_http
# Output envoy logs to stdout
access_log:
- name: envoy.access_loggers.stdout
typed_config:
"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
# Modify as required
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: httpbin
http_filters:
- name: wasmtest
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
name: wasmtest
vm_config:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/plugin.wasm
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
# Modify as required
value: |
{{ .JSONExample }}
- name: envoy.filters.http.router
clusters:
- name: httpbin
connect_timeout: 30s
type: LOGICAL_DNS
# Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin
port_value: 80
`
)
type DockerCompose struct {
TestPath string
ProductPath string
}
type Envoy struct {
JSONExample string
}
func genDockerComposeYAML(d *DockerCompose, dir string) error {
path := fmt.Sprintf("%s/docker-compose.yaml", dir)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if err = template.Must(template.New("DockerComposeYAML").Parse(dockerComposeYAML)).Execute(f, d); err != nil {
return err
}
return nil
}
func genEnvoyYAML(e *Envoy, dir string) error {
path := fmt.Sprintf("%s/envoy.yaml", dir)
f, err := os.Create(path)
if err != nil {
panic(fmt.Sprintf("failed to create %q: %v\n", path, err))
}
defer f.Close()
if err = template.Must(template.New("EnvoyYAML").Parse(envoyYAML)).Execute(f, e); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,35 @@
// 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 test
import (
"github.com/spf13/cobra"
)
func NewCommand() *cobra.Command {
testCmd := &cobra.Command{
Use: "test",
Aliases: []string{"t"},
Short: "Test WASM plugin locally",
}
testCmd.AddCommand(newCreateCommand())
testCmd.AddCommand(newStartCommand())
testCmd.AddCommand(newStopCommand())
testCmd.AddCommand(newCleanCommand())
testCmd.AddCommand(newLsCommand())
return testCmd
}

View File

@@ -0,0 +1,163 @@
// 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 types
import (
"fmt"
"strings"
"github.com/pkg/errors"
)
type Annotation struct {
Type AnnotationType
I18nType I18nType
Text string
}
type AnnotationType int
const (
// Info
ACategory AnnotationType = iota
AName
ATitle
ADescription
AIconUrl
AVersion
AContactName
AContactUrl
AContactEmail
// Spec
APhase
APriority
// Schema
AScope
AExample
AEnd
AUnknown
)
func str2AnnotationType(typ string) AnnotationType {
switch strings.ToLower(typ) {
case "@category":
return ACategory
case "@name":
return AName
case "@title":
return ATitle
case "@description":
return ADescription
case "@iconurl":
return AIconUrl
case "@version":
return AVersion
case "@contact.name":
return AContactName
case "@contact.url":
return AContactUrl
case "@contact.email":
return AContactEmail
case "@phase":
return APhase
case "@priority":
return APriority
case "@scope":
return AScope
case "@example":
return AExample
case "@end":
return AEnd
default:
return AUnknown
}
}
// GetAnnotations returns all annotations in the comment
func GetAnnotations(comment string) []Annotation {
as := make([]Annotation, 0)
cs := strings.Split(comment, "\n")
for i := 0; i < len(cs); i++ {
a, err := getAnnotationFrom(cs[i])
if err != nil {
continue
}
if a.Type == AExample {
for j := i + 1; j < len(cs); j++ {
if str2AnnotationType(strings.TrimSpace(cs[j])) == AEnd {
break
}
if j == i+1 {
a.Text = fmt.Sprintf("%s", cs[j])
} else {
a.Text = fmt.Sprintf("%s\n%s", a.Text, cs[j])
}
}
}
as = append(as, a)
}
return as
}
func getAnnotationFrom(c string) (Annotation, error) {
// the annotation is like `@AnnotationType [I18nType] Text`
c = strings.TrimSpace(c)
if !strings.HasPrefix(c, "@") {
return Annotation{}, errors.New("invalid annotation")
}
// first param: AnnotationType
idx := strings.Index(c, " ")
if idx == -1 && str2AnnotationType(c) == AUnknown { // only an invalid annotation type
return Annotation{}, errors.New("invalid annotation")
}
// idx != -1 or type != unknown
var typ AnnotationType
if idx == -1 {
typ = str2AnnotationType(c)
} else {
typ = str2AnnotationType(strings.TrimSpace(c[0:idx]))
}
c = strings.TrimSpace(c[idx+1:])
a := Annotation{
Type: typ,
I18nType: I18nDefault,
Text: c,
}
if a.Type != ATitle && a.Type != ADescription { // other annotation types do not define i18n
a.I18nType = I18nUndefined
}
if idx == -1 && typ != AUnknown { // only a valid annotation type
a.Text = ""
}
// second or/and third param: I18nType and Text
idx = strings.Index(c, " ")
if idx == -1 {
return a, nil
}
i18n := str2I18nType(strings.TrimSpace(c[0:idx]))
if i18n == I18nUnknown {
return a, nil
}
a.I18nType = i18n
a.Text = strings.TrimSpace(c[idx+1:])
return a, nil
}

View File

@@ -0,0 +1,176 @@
// 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 types
import (
"bytes"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
"k8s.io/apimachinery/pkg/util/json"
)
func (s JSON) MarshalJSON() ([]byte, error) {
if len(s.Raw) > 0 {
var obj interface{}
err := json.Unmarshal(s.Raw, &obj)
if err != nil {
return []byte("null"), err
}
return json.Marshal(obj)
}
return []byte("null"), nil
}
func (s *JSON) UnmarshalJSON(data []byte) error {
if len(data) > 0 && !bytes.Equal(data, []byte("null")) {
s.Raw = data
}
return nil
}
func (s JSON) MarshalYAML() (interface{}, error) {
if len(s.Raw) > 0 {
var obj interface{}
err := yaml.Unmarshal(s.Raw, &obj)
if err != nil {
return "null", err
}
return obj, nil
}
return "null", nil
}
func (s JSONSchemaPropsOrArray) MarshalJSON() ([]byte, error) {
if len(s.JSONSchemas) > 0 {
return json.Marshal(s.JSONSchemas)
}
return json.Marshal(s.Schema)
}
func (s *JSONSchemaPropsOrArray) UnmarshalJSON(data []byte) error {
var nw JSONSchemaPropsOrArray
var first byte
if len(data) > 1 {
first = data[0]
}
if first == '{' {
var sch JSONSchemaProps
if err := json.Unmarshal(data, &sch); err != nil {
return err
}
nw.Schema = &sch
}
if first == '[' {
if err := json.Unmarshal(data, &nw.JSONSchemas); err != nil {
return err
}
}
*s = nw
return nil
}
func (s JSONSchemaPropsOrArray) MarshalYAML() (interface{}, error) {
if len(s.JSONSchemas) > 0 {
return s.JSONSchemas, nil
}
return s.Schema, nil
}
func (s JSONSchemaPropsOrBool) MarshalJSON() ([]byte, error) {
if s.Schema != nil {
return json.Marshal(s.Schema)
}
if s.Schema == nil && !s.Allows {
return []byte("false"), nil
}
return []byte("true"), nil
}
func (s *JSONSchemaPropsOrBool) UnmarshalJSON(data []byte) error {
var nw JSONSchemaPropsOrBool
switch {
case len(data) == 0:
case data[0] == '{':
var sch JSONSchemaProps
if err := json.Unmarshal(data, &sch); err != nil {
return err
}
nw.Allows = true
nw.Schema = &sch
case len(data) == 4 && string(data) == "true":
nw.Allows = true
case len(data) == 5 && string(data) == "false":
nw.Allows = false
default:
return errors.New("boolean or JSON schema expected")
}
*s = nw
return nil
}
func (s JSONSchemaPropsOrBool) MarshalYAML() (interface{}, error) {
if s.Schema != nil {
return yaml.Marshal(s.Schema)
}
if s.Schema == nil && !s.Allows {
return false, nil
}
return true, nil
}
func (s JSONSchemaPropsOrStringArray) MarshalJSON() ([]byte, error) {
if len(s.Property) > 0 {
return json.Marshal(s.Property)
}
if s.Schema != nil {
return json.Marshal(s.Schema)
}
return []byte("null"), nil
}
func (s *JSONSchemaPropsOrStringArray) UnmarshalJSON(data []byte) error {
var first byte
if len(data) > 1 {
first = data[0]
}
var nw JSONSchemaPropsOrStringArray
if first == '{' {
var sch JSONSchemaProps
if err := json.Unmarshal(data, &sch); err != nil {
return err
}
nw.Schema = &sch
}
if first == '[' {
if err := json.Unmarshal(data, &nw.Property); err != nil {
return err
}
}
*s = nw
return nil
}
func (s JSONSchemaPropsOrStringArray) MarshalYAML() (interface{}, error) {
if len(s.Property) > 0 {
return s.Property, nil
}
if s.Schema != nil {
return s.Schema, nil
}
return "null", nil
}

View File

@@ -0,0 +1,393 @@
// 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 types
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"github.com/iancoleman/orderedmap"
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
)
// WasmPluginMeta is used to describe WASM plugin metadata,
// see https://higress.io/en-us/docs/user/wasm-image-spec/
type WasmPluginMeta struct {
APIVersion string `json:"apiVersion" yaml:"apiVersion"`
Info WasmPluginInfo `json:"info" yaml:"info"`
Spec WasmPluginSpec `json:"spec" yaml:"spec"`
}
func defaultWsamPluginMeta() *WasmPluginMeta {
return &WasmPluginMeta{
APIVersion: "1.0.0",
Info: WasmPluginInfo{
Category: CategoryCustom,
Name: "Unnamed",
XTitleI18n: make(map[I18nType]string),
XDescriptionI18n: make(map[I18nType]string),
Version: "0.1.0",
},
Spec: WasmPluginSpec{
Phase: PhaseUnspecified,
Priority: 0,
},
}
}
// ParseSpecYAML parses the `spec.yaml` to WasmPluginMeta
func ParseSpecYAML(spec string) (*WasmPluginMeta, error) {
f, err := os.Open(spec)
if err != nil {
return nil, err
}
defer f.Close()
var m WasmPluginMeta
dc := k8syaml.NewYAMLOrJSONDecoder(f, 4096)
if err = dc.Decode(&m); err != nil {
return nil, err
}
return &m, nil
}
// ParseGoSrc parses the config model of the golang WASM plugin project to WasmPluginMeta
func ParseGoSrc(dir, model string) (*WasmPluginMeta, error) {
mp, err := NewModelParser(dir)
if err != nil {
return nil, err
}
m, err := mp.GetModel(model)
if err != nil {
return nil, err
}
meta := defaultWsamPluginMeta()
meta.setByConfigModel(m)
return meta, nil
}
func (meta *WasmPluginMeta) setByConfigModel(model *Model) {
_, schema := recursiveSetSchema(model, nil)
meta.Spec.ConfigSchema.OpenAPIV3Schema = schema
meta.setModelAnnotations(model.Doc)
}
func recursiveSetSchema(model *Model, parent *JSONSchemaProps) (string, *JSONSchemaProps) {
cur := NewJSONSchemaProps()
cur.Type = model.Type
if parent != nil {
cur.HandleFieldAnnotations(model.Doc)
}
newName := cur.HandleFieldTags(model.Tag, parent, model.Name)
if IsArray(model.Type) {
item := NewJSONSchemaProps()
item.Type = GetItemType(cur.Type)
cur.Type = "array"
if IsObject(item.Type) {
item.Properties = make(map[string]JSONSchemaProps)
for _, field := range model.Fields {
name, child := recursiveSetSchema(&field, cur)
item.Properties[name] = *child
}
}
cur.Items = &JSONSchemaPropsOrArray{Schema: item}
} else if IsObject(model.Type) { // type may be `array of object`, and it is handled in the first branch
for _, field := range model.Fields {
name, child := recursiveSetSchema(&field, cur)
cur.Properties[name] = *child
}
}
return newName, cur
}
func (meta *WasmPluginMeta) setModelAnnotations(comment string) {
as := GetAnnotations(comment)
for _, a := range as {
switch a.Type {
// Info
case ACategory:
meta.Info.Category = Category(a.Text)
case AName:
meta.Info.Name = a.Text
case ATitle:
if meta.Info.Title == "" {
meta.Info.Title = a.Text
}
meta.Info.XTitleI18n[a.I18nType] = a.Text
case ADescription:
if meta.Info.Description == "" {
meta.Info.Description = a.Text
}
meta.Info.XDescriptionI18n[a.I18nType] = a.Text
case AIconUrl:
meta.Info.IconUrl = a.Text
case AVersion:
meta.Info.Version = a.Text
case AContactName:
meta.Info.Contact.Name = a.Text
case AContactUrl:
meta.Info.Contact.Url = a.Text
case AContactEmail:
meta.Info.Contact.Email = a.Text
// Spec
case APhase:
meta.Spec.Phase = Phase(a.Text)
case APriority:
priority, err := strconv.ParseInt(a.Text, 10, 64)
if err != nil {
priority = 0
}
meta.Spec.Priority = priority
// Schema
case AExample:
meta.Spec.ConfigSchema.OpenAPIV3Schema.Example = &JSON{Raw: []byte(a.Text)}
case AScope:
meta.Spec.ConfigSchema.OpenAPIV3Schema.Scope = Scope(a.Text)
}
}
}
type WasmPluginInfo struct {
Category Category `json:"category" yaml:"category"`
Name string `json:"name" yaml:"name"`
Title string `json:"title,omitempty" yaml:"title,omitempty"`
XTitleI18n map[I18nType]string `json:"x-title-i18n,omitempty" yaml:"x-title-i18n,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
XDescriptionI18n map[I18nType]string `json:"x-description-i18n,omitempty" yaml:"x-description-i18n,omitempty"`
IconUrl string `json:"iconUrl,omitempty" yaml:"iconUrl,omitempty"`
Version string `json:"version" yaml:"version"`
Contact Contact `json:"contact,omitempty" yaml:"contact,omitempty"`
}
type Category string
const (
CategoryAuth Category = "auth"
CategorySecurity Category = "security"
CategoryProtocol Category = "protocol"
CategoryFlowControl Category = "flow-control"
CategoryFlowMonitor Category = "flow-monitor"
CategoryCustom Category = "custom"
CategoryDefault = CategoryCustom
)
const (
IconAuth = "https://img.alicdn.com/imgextra/i4/O1CN01BPFGlT1pGZ2VDLgaH_!!6000000005333-2-tps-42-42.png"
IconSecurity = "https://img.alicdn.com/imgextra/i1/O1CN01jKT9vC1O059vNaq5u_!!6000000001642-2-tps-42-42.png"
IconProtocol = "https://img.alicdn.com/imgextra/i2/O1CN01xIywow1mVGuRUjbhe_!!6000000004959-2-tps-42-42.png"
IconFlowControl = "https://img.alicdn.com/imgextra/i3/O1CN01bAFa9k1t1gdQcVTH0_!!6000000005842-2-tps-42-42.png"
IconFlowMonitor = "https://img.alicdn.com/imgextra/i4/O1CN01aet3s61MoLOEEhRIo_!!6000000001481-2-tps-42-42.png"
IconCustom = "https://img.alicdn.com/imgextra/i1/O1CN018iKKih1iVx287RltL_!!6000000004419-2-tps-42-42.png"
IconDefault = IconCustom
)
func Category2IconUrl(category Category) string {
switch category {
case CategoryAuth:
return IconAuth
case CategorySecurity:
return IconSecurity
case CategoryProtocol:
return IconProtocol
case CategoryFlowControl:
return IconFlowControl
case CategoryFlowMonitor:
return IconFlowMonitor
case CategoryCustom:
return IconCustom
default:
return IconDefault
}
}
type I18nType string
const (
I18nZH_CN I18nType = "zh-CN" // default
I18nEN_US I18nType = "en-US"
I18nUndefined I18nType = "undefined" // i18n type is empty in the annotation
I18nUnknown I18nType = "unknown"
I18nDefault = I18nEN_US
)
func str2I18nType(typ string) I18nType {
switch strings.ToLower(typ) {
case "zh-cn":
return I18nZH_CN
case "en-us":
return I18nEN_US
default:
return I18nUnknown
}
}
type Contact struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Url string `json:"url,omitempty" yaml:"url,omitempty"`
Email string `json:"email,omitempty" yaml:"email,omitempty"`
}
type WasmPluginSpec struct {
// Phase refers to https://istio.io/latest/docs/reference/config/proxy_extensions/wasm-plugin/#PluginPhase
Phase Phase `json:"phase" yaml:"phase"`
// Priority refers to https://istio.io/latest/docs/reference/config/proxy_extensions/wasm-plugin/#WasmPlugin
Priority int64 `json:"priority" yaml:"priority"`
ConfigSchema ConfigSchema `json:"configSchema" yaml:"configSchema"`
}
type Phase string
const (
PhaseUnspecified Phase = "UNSPECIFIED_PHASE"
PhaseAuthn Phase = "AUTHN"
PhaseAuthz Phase = "AUTHZ"
PhaseStats Phase = "STATS"
PhaseDefault = PhaseUnspecified
)
type ConfigSchema struct {
OpenAPIV3Schema *JSONSchemaProps `json:"openAPIV3Schema" yaml:"openAPIV3Schema"`
}
// GetConfigExample returns a pretty WASM plugin config example
func (meta *WasmPluginMeta) GetConfigExample() string {
s := meta.Spec.ConfigSchema.OpenAPIV3Schema
if s != nil {
return s.GetExample()
}
return ""
}
// getLanguageUnionOrderMap returns a ordered map of language union of title and description.
// If there is a language type in title that description does not have, the value is "No description"
func (meta *WasmPluginMeta) getLanguageUnionOrderMap() *orderedmap.OrderedMap {
m := orderedmap.New()
for i18n, desc := range meta.Info.XDescriptionI18n {
m.Set(string(i18n), desc)
}
for i18n := range meta.Info.XTitleI18n {
if _, ok := m.Get(string(i18n)); !ok {
m.Set(string(i18n), "No description")
}
}
if len(m.Keys()) == 0 {
m.Set(string(I18nEN_US), "No description")
}
m.SortKeys(sort.Strings)
return m
}
// WasmUsage is used to describe WASM plugin usage in the Markdown document
type WasmUsage struct {
I18nType I18nType
Description string
ConfigEntries []ConfigEntry
Example string
}
type ConfigEntry struct {
Name string
Type string
Requirement string
Default string
Description string
}
// GetUsages returns WASM plugin usages in different languages
func (meta *WasmPluginMeta) GetUsages() ([]WasmUsage, error) {
usages := make([]WasmUsage, 0)
example := meta.GetConfigExample()
m := meta.getLanguageUnionOrderMap()
for _, i18n := range m.Keys() {
desc, ok := m.Get(i18n)
if !ok {
continue
}
u := WasmUsage{
I18nType: I18nType(i18n),
Description: desc.(string),
ConfigEntries: make([]ConfigEntry, 0),
Example: example,
}
getConfigEntries(meta.Spec.ConfigSchema.OpenAPIV3Schema, &u.ConfigEntries, I18nType(i18n))
usages = append(usages, u)
}
return usages, nil
}
func getConfigEntries(schema *JSONSchemaProps, entries *[]ConfigEntry, i18n I18nType) {
doGetConfigEntries(schema, entries, "", "", i18n, false)
}
func doGetConfigEntries(schema *JSONSchemaProps, entries *[]ConfigEntry, parentName, name string, i18n I18nType, required bool) {
newName := constructName(parentName, name)
switch schema.Type {
case "object":
m := schema.GetPropertiesOrderMap()
for _, fieldName := range m.Keys() {
val, ok := m.Get(fieldName)
if !ok {
continue
}
props := val.(JSONSchemaProps)
required = schema.IsRequired(fieldName)
doGetConfigEntries(&props, entries, newName, fieldName, i18n, required)
}
case "array":
itemType := schema.Items.Schema.Type
e := ConfigEntry{
Name: newName,
Type: ArrayPrefix + itemType,
Requirement: schema.JoinRequirementsBy(i18n, required),
Default: schema.GetDefaultValue(),
Description: schema.XDescriptionI18n[i18n],
}
*entries = append(*entries, e)
if itemType == "object" {
doGetConfigEntries(schema.Items.Schema, entries, newName+"[*]", "", i18n, false)
}
default:
e := ConfigEntry{
Name: newName,
Type: schema.Type,
Requirement: schema.JoinRequirementsBy(i18n, required),
Default: schema.GetDefaultValue(),
Description: schema.XDescriptionI18n[i18n],
}
*entries = append(*entries, e)
}
}
func constructName(parent, name string) string {
newName := name
if parent != "" {
if name != "" {
newName = fmt.Sprintf("%s.%s", parent, name)
} else {
newName = parent
}
}
return newName
}

View File

@@ -0,0 +1,391 @@
// 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 types
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/fatih/structtag"
"github.com/pkg/errors"
)
const (
ArrayPrefix = "array of "
ObjectSuffix = "object"
)
// IsArray returns true if the given type is an `array of <type>`
func IsArray(typ string) bool {
return strings.HasPrefix(typ, ArrayPrefix)
}
// GetItemType returns the item type of array, e.g.: array of int -> int
func GetItemType(typ string) string {
return strings.TrimPrefix(typ, ArrayPrefix)
}
// IsObject returns true if the given type is an `object` or an `array of object`
func IsObject(typ string) bool {
return strings.HasSuffix(typ, ObjectSuffix)
}
var (
ErrInvalidModel = errors.New("invalid model")
ErrInvalidFiledType = errors.New("invalid field type")
)
type ModelParser struct {
structs map[string]*astNode
// alias for a basic type, such as type MyInt int: MyInt -> int
// TODO(WeixinX): Support alias for package name
alias map[string]*astNode
}
type Model struct {
Name string
Type string
Doc string
Tag string
Fields []Model
}
type astNode struct {
name string
doc string
expr ast.Expr
}
func (m *Model) Inspect(f func(model *Model) bool) {
ctn := f(m)
if !ctn {
return
}
for _, field := range m.Fields {
field.Inspect(f)
}
}
// NewModelParser new a model parser based on the dir where the given model exists
func NewModelParser(dir string) (*ModelParser, error) {
pkgs, err := walkGoSrc(dir)
if err != nil {
return nil, err
}
p := &ModelParser{
structs: make(map[string]*astNode),
alias: make(map[string]*astNode),
}
for _, pkg := range pkgs {
for _, f := range pkg.Files {
for _, decl := range f.Decls {
x, ok := decl.(*ast.GenDecl)
if !ok || x.Tok != token.TYPE {
continue
}
for _, spec := range x.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
switch t := ts.Type.(type) {
case *ast.StructType:
if !t.Struct.IsValid() {
continue
}
s := &astNode{
name: ts.Name.String(),
expr: t,
}
if pkg.Name != "main" { // ignore main package prefix
s.name = fmt.Sprintf("%s.%s", pkg.Name, s.name)
}
if x.Doc != nil {
s.doc = x.Doc.Text()
}
p.structs[s.name] = s
case *ast.InterfaceType:
continue
default: // for alias, such as `type MyInt int`
alias := ts.Name.String()
if pkg.Name != "main" {
alias = fmt.Sprintf("%s.%s", pkg.Name, alias)
}
name, err := p.getModelName(t)
if err != nil {
continue
}
p.alias[alias] = &astNode{
name: name,
expr: t,
}
}
}
}
}
}
// gets the true type (ast node) of the alias
for alias := range p.alias {
n := p.recursiveAlias(alias)
if n != nil {
p.alias[alias] = n
}
}
return p, nil
}
func walkGoSrc(dir string) (map[string]*ast.Package, error) {
info, err := os.Stat(dir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, errors.Errorf("%q is not a directory", dir)
}
fset := token.NewFileSet()
pkgs := make(map[string]*ast.Package)
walk := func(path string, info fs.FileInfo, err error) error {
if !info.IsDir() {
return nil
}
tmp, err := parser.ParseDir(fset, path, nil, parser.ParseComments)
if err != nil {
return err
}
for k, v := range tmp {
pkgs[k] = v
}
return nil
}
if err := filepath.Walk(dir, walk); err != nil {
return nil, errors.Wrapf(err, "failed to walk path %q", dir)
}
return pkgs, nil
}
func (p *ModelParser) recursiveAlias(alias string) *astNode {
if s, ok := p.structs[alias]; ok {
return s
}
if n, ok := p.alias[alias]; ok {
if n.name != alias {
ret := p.recursiveAlias(n.name)
if ret != nil {
return ret
}
}
return n
}
return nil
}
// GetModel return the specified model
func (p *ModelParser) GetModel(model string) (*Model, error) {
fields, err := p.parseModelFields(model)
if err != nil {
return nil, err
}
m := &Model{
Name: model,
Type: "object",
Fields: fields,
}
m.setDoc(p.structs[model].doc)
return m, nil
}
func (p *ModelParser) parseModelFields(model string) (fields []Model, err error) {
var s *astNode
if _, ok := p.structs[model]; ok {
s = p.structs[model]
} else if _, ok = p.alias[model]; ok {
s = p.alias[model]
} else {
return nil, ErrInvalidModel
}
st, ok := s.expr.(*ast.StructType)
if !ok || st.Fields == nil {
return nil, ErrInvalidModel
}
pkgName := ""
if idx := strings.Index(model, "."); idx != -1 {
pkgName = model[:idx+1] // pkgName includes "."
}
for _, field := range st.Fields.List {
if skipField(field) {
continue
}
fd := Model{Name: field.Names[0].String()}
if field.Doc != nil {
fd.setDoc(field.Doc.Text())
}
if field.Tag != nil {
ignore, err := fd.setTag(field.Tag.Value)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse tag %q of the field %q", fd.Tag, fd.Name)
}
if ignore {
continue
}
}
fd.Type, err = p.parseFiledType(pkgName, field.Type)
if err != nil {
return nil, err
}
if IsObject(fd.Type) {
subModel, err := p.getModelName(field.Type)
if err != nil {
return nil, err
}
fd.Fields, err = p.parseModelFields(subModel)
if err != nil {
return nil, err
}
}
fields = append(fields, fd)
}
return fields, nil
}
func skipField(field *ast.Field) bool {
name := field.Names
return field == nil || name == nil || len(name) < 1 || name[0] == nil || name[0].String() == "_"
}
func (m *Model) setDoc(str string) {
m.Doc = strings.TrimSpace(str)
}
func (m *Model) setTag(str string) (bool, error) {
str = strings.Trim(str, "` ")
if str == "" {
return false, nil
}
ignore := false
tag, err := structtag.Parse(str)
if err != nil {
return false, err
}
m.Tag = str
val, err := tag.Get("yaml")
if err == nil {
if val.Name == "-" || val.Name == "" {
ignore = true
}
}
return ignore, nil
}
func (p *ModelParser) getModelName(typ ast.Expr) (string, error) {
return p.doGetModelName("", typ)
}
func (p *ModelParser) doGetModelName(pkgName string, typ ast.Expr) (string, error) {
switch t := typ.(type) {
case *ast.StarExpr: // *int -> int
return p.doGetModelName(pkgName, t.X)
case *ast.ArrayType: // slice or array
return p.doGetModelName(pkgName, t.Elt)
case *ast.SelectorExpr: // <pkg_name>.<field_name>
pkg, ok := t.X.(*ast.Ident)
if !ok {
return "", ErrInvalidFiledType
}
pName := pkg.Name + "."
return p.doGetModelName(pName, t.Sel)
case *ast.Ident:
return pkgName + t.Name, nil
default:
return "", ErrInvalidFiledType
}
}
func (p *ModelParser) parseFiledType(pkgName string, typ ast.Expr) (string, error) {
switch t := typ.(type) {
case *ast.StructType: // nested struct
return string(JsonTypeObject), nil
case *ast.StarExpr: // *int -> int
return p.parseFiledType(pkgName, t.X)
case *ast.ArrayType: // slice or array
ret, err := p.parseFiledType(pkgName, t.Elt)
if err != nil {
return "", err
}
return ArrayPrefix + ret, nil
case *ast.SelectorExpr: // <pkg_name>.<field_name>
pkg, ok := t.X.(*ast.Ident)
if !ok {
return "", ErrInvalidFiledType
}
pName := pkg.Name + "."
return p.parseFiledType(pName, t.Sel)
case *ast.Ident:
fName := pkgName + t.Name
if _, ok := p.structs[fName]; ok {
return string(JsonTypeObject), nil
}
if alias, ok := p.alias[fName]; ok {
return p.parseFiledType(pkgName, alias.expr)
}
jsonType, err := convert2JsonType(t.Name)
return string(jsonType), err
default:
return "", ErrInvalidFiledType
}
}
func convert2JsonType(typ string) (JsonType, error) {
switch typ {
case "int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64":
return JsonTypeInteger, nil
case "float32", "float64":
return JsonTypeNumber, nil
case "bool":
return JsonTypeBoolean, nil
case "string":
return JsonTypeString, nil
case "struct":
return JsonTypeObject, nil
default:
return "", ErrInvalidFiledType
}
}
type JsonType string
const (
JsonTypeInteger JsonType = "integer"
JsonTypeNumber JsonType = "number"
JsonTypeBoolean JsonType = "boolean"
JsonTypeString JsonType = "string"
JsonTypeObject JsonType = "object"
JsonTypeArray JsonType = "array"
)

View File

@@ -0,0 +1,379 @@
// 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 types
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetModel(t *testing.T) {
var (
BasicStructField = []Model{
{
Name: "Name",
Type: "string",
},
{
Name: "Age",
Type: "integer",
},
{
Name: "Married",
Type: "boolean",
},
{
Name: "Salary",
Type: "number",
},
}
ExternalStructField = []Model{
{
Name: "one",
Type: "string",
},
{
Name: "two",
Type: "integer",
},
{
Name: "three",
Type: "array of boolean",
},
}
NestedStructField = []Model{
{
Name: "Simple",
Type: "string",
},
{
Name: "Complex",
Type: "array of integer",
},
}
)
cases := []struct {
name string
expected *Model
errMsg string
}{
{
name: "TestBasicStruct",
expected: &Model{
Name: "TestBasicStruct",
Type: "object",
Fields: BasicStructField,
},
},
{
name: "TestComplexStruct",
expected: &Model{
Name: "TestComplexStruct",
Type: "object",
Fields: []Model{
{
Name: "Array",
Type: "array of integer",
},
{
Name: "Slice",
Type: "array of string",
},
{
Name: "Pointer",
Type: "string",
},
{
Name: "PPPointer",
Type: "boolean",
},
{
Name: "ArrayPointer",
Type: "array of integer",
},
{
Name: "SlicePointer",
Type: "array of integer",
},
{
Name: "StructPointerSlice",
Type: "array of object",
Fields: BasicStructField,
},
{
Name: "StructArrayPointer",
Type: "array of object",
Fields: BasicStructField,
},
},
},
},
{
name: "TestAliasStruct",
expected: &Model{
Name: "TestAliasStruct",
Type: "object",
Fields: []Model{
{
Name: "MyString",
Type: "string",
},
{
Name: "MyPointerInt",
Type: "integer",
},
{
Name: "MyStruct",
Type: "object",
Fields: BasicStructField,
},
},
},
},
{
name: "TestExternalStruct",
expected: &Model{
Name: "TestExternalStruct",
Type: "object",
Fields: []Model{
{
Name: "InternalFloat",
Type: "number",
},
{
Name: "ExStruct",
Type: "object",
Fields: ExternalStructField,
},
{
Name: "ExternalInt",
Type: "integer",
},
{
Name: "ExBool",
Type: "boolean",
},
{
Name: "ExSlice",
Type: "array of string",
},
},
},
},
{
name: "TestNestedStruct",
expected: &Model{
Name: "TestNestedStruct",
Type: "object",
Fields: []Model{
{
Name: "NestedStruct",
Type: "object",
Fields: []Model{
{
Name: "NestedStruct",
Type: "object",
Fields: NestedStructField,
},
{
Name: "NestedInt",
Type: "integer",
},
{
Name: "NestedString",
Type: "string",
},
},
},
},
},
},
{
name: "ext.TestExStruct",
expected: &Model{
Name: "ext.TestExStruct",
Type: "object",
Fields: ExternalStructField,
},
},
{
name: "ext.TestNestedStruct",
expected: &Model{
Name: "ext.TestNestedStruct",
Type: "object",
Fields: []Model{
{
Name: "NestedStruct",
Type: "object",
Fields: NestedStructField,
},
{
Name: "NestedInt",
Type: "integer",
},
{
Name: "NestedString",
Type: "string",
},
},
},
},
}
p, err := NewModelParser("./testdata/types")
require.NoError(t, err)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
actual, err := p.GetModel(c.name)
if c.errMsg != "" {
require.EqualError(t, err, c.errMsg)
} else {
require.NoError(t, err)
require.Equal(t, c.expected, actual)
}
})
}
}
func TestParseStructAndAlias(t *testing.T) {
cases := []struct {
name string
dir string
expectedStructs map[string]struct{}
expectedAlias map[string]string
}{
{
name: "Basic",
dir: "./testdata/types",
expectedStructs: map[string]struct{}{
"TestBasicStruct": {},
"TestComplexStruct": {},
"TestAliasStruct": {},
"TestExternalStruct": {},
"TestNestedStruct": {},
"ext.TestExStruct": {},
"ext.TestNestedStruct": {},
"nested.TestNestedStruct": {},
},
expectedAlias: map[string]string{
"MyString": "string",
"MyPointerInt": "int",
"MyStruct": "TestBasicStruct",
"NestedAlias": "nested.TestNestedStruct",
"NestedBasicAlias": "bool",
"ext.ExAlias": "nested.TestNestedStruct",
"ext.ExPointerInt": "int",
"ext.ExBool": "bool",
"ext.ExSlice": "string",
"nested.NestedInt": "int",
"nested.NestedString": "string",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p, err := NewModelParser(c.dir)
require.NoError(t, err)
actualStructs := make(map[string]struct{})
for _, s := range p.structs {
actualStructs[s.name] = struct{}{}
}
require.Equal(t, c.expectedStructs, actualStructs)
actualAlias := make(map[string]string)
for name, alias := range p.alias {
actualAlias[name] = alias.name
}
require.Equal(t, c.expectedAlias, actualAlias)
})
}
}
func TestStructFieldDocAndTag(t *testing.T) {
var BasicStructField = []Model{
{
Name: "Name",
Type: "string",
Doc: "Name, specify username",
Tag: `yaml:"name" required:"true" minLength:"1" maxLength:"32"`,
},
{
Name: "Age",
Type: "integer",
Doc: "Age, specify age",
Tag: `yaml:"age" required:"true" minimum:"0" maximum:"140"`,
},
{
Name: "Married",
Type: "boolean",
Doc: "Married, specify marital status [true, false]\nand optional",
Tag: `yaml:"married" required:"false"`,
},
{
Name: "Salary",
Type: "number",
Doc: "Salary, specify income status, optional",
Tag: `yaml:"salary" required:"false"`,
},
{
Name: "Children",
Type: "array of string",
Doc: "Children, specify a list of children's names, optional",
Tag: `yaml:"children" required:"false"`,
},
}
cases := []struct {
name string
model string
expected []Model
}{
{
name: "TestBasicDocTag",
model: "TestBasicDocTag",
expected: BasicStructField,
},
{
name: "TestNestedStructDocTag",
model: "TestNestedStructDocTag",
expected: []Model{
{
Name: "Struct",
Type: "array of object",
Doc: "This is the comment of the nested struct field",
Tag: `yaml:"struct" required:"true" minItems:"1" maxItems:"10"`,
Fields: BasicStructField,
},
},
},
}
p, err := NewModelParser("./testdata/doc_tag")
require.NoError(t, err)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m, err := p.GetModel(c.model)
require.NoError(t, err)
require.Equal(t, c.expected, m.Fields)
})
}
}

View File

@@ -0,0 +1,426 @@
// 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 types
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin/utils"
"github.com/fatih/structtag"
"github.com/iancoleman/orderedmap"
)
// JSONSchemaProps is a JSON-Schema following Specification Draft 4 (http://json-schema.org/).
// Borrowed from https://github.com/kubernetes/apiextensions-apiserver/blob/master/pkg/apis/apiextensions/v1/types_jsonschema.go
type JSONSchemaProps struct {
ID string `json:"id,omitempty" yaml:"id,omitempty"`
Schema JSONSchemaURL `json:"$schema,omitempty" yaml:"$schema,omitempty"`
Ref *string `json:"$ref,omitempty" yaml:"$ref,omitempty"`
Type string `json:"type,omitempty" yaml:"type,omitempty"`
Format string `json:"format,omitempty" yaml:"format,omitempty"`
Scope Scope `json:"scope,omitempty" yaml:"scope,omitempty"`
Title string `json:"title,omitempty" yaml:"title,omitempty"`
XTitleI18n map[I18nType]string `json:"x-title-i18n,omitempty" yaml:"x-title-i18n,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
XDescriptionI18n map[I18nType]string `json:"x-description-i18n,omitempty" yaml:"x-description-i18n,omitempty"`
Default *JSON `json:"default,omitempty" yaml:"default,omitempty"`
Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"`
ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"`
ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"`
MinLength *int64 `json:"minLength,omitempty" yaml:"minLength,omitempty"`
MaxLength *int64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty"`
Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"`
MaxItems *int64 `json:"maxItems,omitempty" yaml:"maxItems,omitempty"`
MinItems *int64 `json:"minItems,omitempty" yaml:"minItems,omitempty"`
UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"`
MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"`
Enum []JSON `json:"enum,omitempty" yaml:"enum,omitempty"`
MinProperties *int64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"`
MaxProperties *int64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"`
Required []string `json:"required,omitempty" yaml:"required,omitempty"`
Items *JSONSchemaPropsOrArray `json:"items,omitempty" yaml:"items,omitempty"`
AllOf []JSONSchemaProps `json:"allOf,omitempty" yaml:"allOf,omitempty"`
OneOf []JSONSchemaProps `json:"oneOf,omitempty" yaml:"oneOf,omitempty"`
AnyOf []JSONSchemaProps `json:"anyOf,omitempty" yaml:"anyOf,omitempty"`
Not *JSONSchemaProps `json:"not,omitempty" yaml:"not,omitempty"`
Properties map[string]JSONSchemaProps `json:"properties,omitempty" yaml:"properties,omitempty"`
AdditionalProperties *JSONSchemaPropsOrBool `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"`
PatternProperties map[string]JSONSchemaProps `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"`
Dependencies JSONSchemaDependencies `json:"dependencies,omitempty" yaml:"dependencies,omitempty"`
AdditionalItems *JSONSchemaPropsOrBool `json:"additionalItems,omitempty" yaml:"additionalItems,omitempty"`
Definitions JSONSchemaDefinitions `json:"definitions,omitempty" yaml:"definitions,omitempty"`
ExternalDocs *ExternalDocumentation `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"`
Example *JSON `json:"example,omitempty" yaml:"example,omitempty"`
Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"`
}
type Scope string
const (
ScopeGlobal Scope = "GLOBAL"
ScopeInstance Scope = "INSTANCE"
ScopeAll Scope = "ALL"
ScopeDefault = ScopeInstance
)
// JSON represents any valid JSON value.
// These types are supported: bool, int64, float64, string, []interface{}, map[string]interface{} and nil.
type JSON struct {
Raw []byte `json:"-" yaml:"-"`
}
// JSONSchemaPropsOrArray represents a value that can either be a JSONSchemaProps
// or an array of JSONSchemaProps. Mainly here for serialization purposes.
type JSONSchemaPropsOrArray struct {
Schema *JSONSchemaProps
JSONSchemas []JSONSchemaProps
}
// JSONSchemaPropsOrBool represents JSONSchemaProps or a boolean value.
// Defaults to true for the boolean property.
type JSONSchemaPropsOrBool struct {
Allows bool
Schema *JSONSchemaProps
}
// JSONSchemaDependencies represent a dependencies property.
type JSONSchemaDependencies map[string]JSONSchemaPropsOrStringArray
// JSONSchemaPropsOrStringArray represents a JSONSchemaProps or a string array.
type JSONSchemaPropsOrStringArray struct {
Schema *JSONSchemaProps
Property []string
}
// JSONSchemaURL represents a schema url.
type JSONSchemaURL string
// JSONSchemaDefinitions contains the models explicitly defined in this spec.
type JSONSchemaDefinitions map[string]JSONSchemaProps
// ExternalDocumentation allows referencing an external resource for extended documentation.
type ExternalDocumentation struct {
Description string `json:"description,omitempty" yaml:"description,omitempty"`
URL string `json:"url,omitempty" yaml:"url,omitempty"`
}
func NewJSONSchemaProps() *JSONSchemaProps {
return &JSONSchemaProps{
XTitleI18n: make(map[I18nType]string),
XDescriptionI18n: make(map[I18nType]string),
Properties: make(map[string]JSONSchemaProps),
}
}
// IsRequired determines whether the given `name` field is required
func (s *JSONSchemaProps) IsRequired(name string) bool {
req := false
for _, n := range s.Required {
if name == n {
req = true
break
}
}
return req
}
// GetDefaultValue returns the default value of the schema
func (s *JSONSchemaProps) GetDefaultValue() string {
d := "-"
if s.Default == nil {
return d
}
if len(s.Default.Raw) > 0 {
d = string(s.Default.Raw)
}
return d
}
// GetExample returns the pretty example of the schema
func (s *JSONSchemaProps) GetExample() string {
ret := ""
if s.Example != nil && len(s.Example.Raw) > 0 {
ret = string(s.Example.Raw)
if ret[0] == '{' {
// string(s.Example.Raw) might look like (when the schema is generated through go src):
// {"allow":["consumer1"],"consumers":[{"credential":"admin:123456","name":"consumer1"}]}
var obj interface{}
err := json.Unmarshal(s.Example.Raw, &obj)
if err != nil {
return ""
}
b, err := utils.MarshalYamlWithIndent(obj, 2)
if err != nil {
return ""
}
ret = string(b)
}
}
return ret
}
// GetPropertiesOrderMap converts the schema Properties map to
// an ordered map (dictionary order) and returns it
func (s *JSONSchemaProps) GetPropertiesOrderMap() *orderedmap.OrderedMap {
m := orderedmap.New()
for name, prop := range s.Properties {
m.Set(name, prop)
}
m.SortKeys(sort.Strings)
return m
}
// HandleFieldAnnotations parses the comment (annotations look like `// @<KEY> [LANGUAGE] <VALUE>`)
// and sets the schema properties
func (s *JSONSchemaProps) HandleFieldAnnotations(comment string) {
as := GetAnnotations(comment)
for _, a := range as {
switch a.Type {
case ATitle:
if s.Title == "" {
s.Title = a.Text
}
s.XTitleI18n[a.I18nType] = a.Text
case ADescription:
if s.Description == "" {
s.Description = a.Text
}
s.XDescriptionI18n[a.I18nType] = a.Text
case AScope:
s.Scope = Scope(a.Text)
case AExample:
s.Example = &JSON{Raw: []byte(a.Text)}
}
}
}
// HandleFieldTags parses the struct field tags and sets the schema properties
// TODO: Add more tags (now supported yaml, minimum, maximum, ...)
func (s *JSONSchemaProps) HandleFieldTags(tags string, parent *JSONSchemaProps, fieldName string) string {
if tags == "" {
return fieldName
}
st, err := structtag.Parse(tags)
if err != nil {
return fieldName
}
newName := fieldName
for _, tag := range st.Tags() {
switch tag.Key {
case "yaml":
newName = tag.Name
if s.Title == "" {
s.Title = newName
s.XTitleI18n[I18nDefault] = newName
}
case "required":
required, _ := strconv.ParseBool(tag.Name)
if !required {
continue
}
parent.Required = append(parent.Required, newName)
case "minimum":
min, err := strconv.ParseFloat(tag.Name, 64)
if err != nil {
continue
}
s.Minimum = &min
case "maximum":
max, err := strconv.ParseFloat(tag.Name, 64)
if err != nil {
continue
}
s.Maximum = &max
case "minLength":
minL, err := strconv.ParseInt(tag.Name, 10, 64)
if err != nil {
continue
}
s.MinLength = &minL
case "maxLength":
maxL, err := strconv.ParseInt(tag.Name, 10, 64)
if err != nil {
continue
}
s.MaxLength = &maxL
case "minItems":
minI, err := strconv.ParseInt(tag.Name, 10, 64)
if err != nil {
continue
}
s.MinItems = &minI
case "maxItems":
maxI, err := strconv.ParseInt(tag.Name, 10, 64)
if err != nil {
continue
}
s.MaxItems = &maxI
case "pattern":
s.Pattern = tag.Name
}
}
return newName
}
// JoinRequirementsBy joins the requirements by the given i18n type. Return value looks like:
// required, minLength 10, regular expression "^.*$"
func (s *JSONSchemaProps) JoinRequirementsBy(i18n I18nType, required bool) string {
reqs := s.getRequirements(required)
switch i18n {
case I18nZH_CN:
return strings.Join(reqs[I18nZH_CN], "")
case I18nEN_US:
fallthrough
default:
return strings.Join(reqs[I18nDefault], ", ")
}
}
func (s *JSONSchemaProps) getRequirements(required bool) map[I18nType][]string {
reqs := make(map[I18nType][]string)
for i18n, str := range s.GetRequired(required) {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetMinimum() {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetMaximum() {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetMinLength() {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetMaxLength() {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetMinItems() {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetMaxItems() {
reqs[i18n] = append(reqs[i18n], str)
}
for i18n, str := range s.GetPattern() {
reqs[i18n] = append(reqs[i18n], str)
}
return reqs
}
func (s *JSONSchemaProps) GetMinimum() map[I18nType]string {
if s.Minimum == nil {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("最小值 %f", *s.Minimum),
I18nEN_US: fmt.Sprintf("minimum %f", *s.Minimum),
}
}
func (s *JSONSchemaProps) GetMaximum() map[I18nType]string {
if s.Maximum == nil {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("最大值 %f", *s.Maximum),
I18nEN_US: fmt.Sprintf("maximum %f", *s.Maximum),
}
}
func (s *JSONSchemaProps) GetMinLength() map[I18nType]string {
if s.MinLength == nil {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("最小长度 %d", *s.MinLength),
I18nEN_US: fmt.Sprintf("minLength %d", *s.MinLength),
}
}
func (s *JSONSchemaProps) GetMaxLength() map[I18nType]string {
if s.MaxLength == nil {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("最大长度 %d", *s.MaxLength),
I18nEN_US: fmt.Sprintf("maxLength %d", *s.MaxLength),
}
}
func (s *JSONSchemaProps) GetPattern() map[I18nType]string {
if s.Pattern == "" {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("正则表达式 %q", s.Pattern),
I18nEN_US: fmt.Sprintf("regular expression %q", s.Pattern),
}
}
func (s *JSONSchemaProps) GetMinItems() map[I18nType]string {
if s.MinItems == nil {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("最小 item 个数 %d", *s.MinItems),
I18nEN_US: fmt.Sprintf("minItems %d", *s.MinItems),
}
}
func (s *JSONSchemaProps) GetMaxItems() map[I18nType]string {
if s.MaxItems == nil {
return nil
}
return map[I18nType]string{
I18nZH_CN: fmt.Sprintf("最大 item 个数 %d", *s.MaxItems),
I18nEN_US: fmt.Sprintf("maxItems %d", *s.MaxItems),
}
}
func (s *JSONSchemaProps) GetRequired(req bool) map[I18nType]string {
if req {
return map[I18nType]string{
I18nZH_CN: "必填",
I18nEN_US: "required",
}
}
return map[I18nType]string{
I18nZH_CN: "选填",
I18nEN_US: "optional",
}
}

View File

@@ -0,0 +1,45 @@
// 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 main
// TestBasicDocTag This is a test struct for documents(comments) and tags
type TestBasicDocTag struct {
// Name, specify username
Name string `yaml:"name" required:"true" minLength:"1" maxLength:"32"`
// Age, specify age
Age uint `yaml:"age" required:"true" minimum:"0" maximum:"140" `
// Married, specify marital status [true, false]
// and optional
Married bool `yaml:"married" required:"false"`
// Salary, specify income status, optional
Salary float64 `yaml:"salary" required:"false"`
// Children, specify a list of children's names, optional
Children []string `yaml:"children" required:"false"`
// ignore1
Ignore1 string `yaml:"-"`
// ignore 2
Ignore2 string `yaml:""`
}
type TestNestedStructDocTag struct {
// This is the comment of the nested struct field
Struct []*TestBasicDocTag `yaml:"struct" required:"true" minItems:"1" maxItems:"10"`
}

View File

@@ -0,0 +1,34 @@
// 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 ext
import "github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types/testdata/types/ext/nested"
type TestExStruct struct {
one string
two *int
three []bool
}
type ExPointerInt **int
type ExBool bool
type ExSlice []*string
type ExAlias nested.TestNestedStruct
type TestNestedStruct struct {
NestedStruct *nested.TestNestedStruct
NestedInt *nested.NestedInt
NestedString nested.NestedString
}

View File

@@ -0,0 +1,23 @@
// 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 nested
type TestNestedStruct struct {
Simple string
Complex **[]*int
}
type NestedInt ***int
type NestedString string

View File

@@ -0,0 +1,70 @@
// 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 main
import "github.com/alibaba/higress/pkg/cmd/hgctl/plugin/types/testdata/types/ext"
type TestBasicStruct struct {
Name string
Age uint
Married bool
Salary float64
}
type TestComplexStruct struct {
Array [2]int
Slice []string
Pointer *string
PPPointer ***bool
ArrayPointer [2]*int
SlicePointer []*int
StructPointerSlice []*TestBasicStruct
StructArrayPointer *[]TestBasicStruct
_ struct {
one int
two string
}
}
type TestAliasStruct struct {
MyString *MyString
MyPointerInt MyPointerInt
MyStruct MyStruct
}
type MyString string
type MyPointerInt *int
type MyStruct TestBasicStruct
type NestedAlias ext.ExAlias
type NestedBasicAlias ext.ExBool
type TestExternalStruct struct {
InternalFloat float64
ExStruct ext.TestExStruct
ExternalInt ext.ExPointerInt
ExBool ext.ExBool
ExSlice ext.ExSlice
}
type TestNestedStruct struct {
NestedStruct *ext.TestNestedStruct
}
type MyInterface interface {
}
var MyConst bool
var MyVar int

View File

@@ -0,0 +1,102 @@
// 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 uninstall
import (
"context"
"fmt"
"io"
k8s "github.com/alibaba/higress/pkg/cmd/hgctl/kubernetes"
"github.com/alibaba/higress/pkg/cmd/options"
"github.com/pkg/errors"
"github.com/spf13/cobra"
k8serr "k8s.io/apimachinery/pkg/api/errors"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
func NewCommand() *cobra.Command {
var (
name string
all bool
)
uninstallCmd := &cobra.Command{
Use: "uninstall",
Aliases: []string{"u", "uins"},
Short: "Uninstall WASM plugin",
Example: ` # Uninstall WASM plugin using the WasmPlugin name
hgctl plugin uninstall -p example-plugin-name
# Uninstall all WASM plugins
hgctl plugin uninstall -A
`,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(uninstall(cmd.OutOrStdout(), name, all))
},
}
flags := uninstallCmd.PersistentFlags()
options.AddKubeConfigFlags(flags)
k8s.AddHigressNamespaceFlags(flags)
flags.StringVarP(&name, "name", "p", "", "Name of the WASM plugin you want to uninstall")
flags.BoolVarP(&all, "all", "A", false, "Delete all installed WASM plugin")
return uninstallCmd
}
func uninstall(w io.Writer, name string, all bool) error {
dynCli, err := k8s.NewDynamicClient(options.DefaultConfigFlags.ToRawKubeConfigLoader())
if err != nil {
return errors.Wrap(err, "failed to build kubernetes dynamic client")
}
cli := k8s.NewWasmPluginClient(dynCli)
ctx := context.TODO()
plugins := make([]string, 0)
if all {
list, err := cli.List(ctx)
if err != nil {
return errors.Wrap(err, "failed to get information of all wasm plugins")
}
for _, item := range list.Items {
plugins = append(plugins, item.GetName())
}
} else {
plugins = append(plugins, name)
}
for _, p := range plugins {
err = deleteOne(ctx, w, cli, p)
if err != nil {
fmt.Fprintln(w, err.Error())
}
}
return nil
}
func deleteOne(ctx context.Context, w io.Writer, cli *k8s.WasmPluginClient, name string) error {
result, err := cli.Delete(ctx, name)
if err != nil && k8serr.IsNotFound(err) {
return errors.Errorf("wasm plugin %q is not found", fmt.Sprintf("%s/%s", k8s.HigressNamespace, name))
} else if err != nil {
return errors.Wrapf(err, "failed to uninstall wasm plugin %q", fmt.Sprintf("%s/%s", k8s.HigressNamespace, name))
}
fmt.Fprintf(w, "Uninstalled wasm plugin %q\n", fmt.Sprintf("%s/%s", result.GetNamespace(), result.GetName()))
return nil
}

View File

@@ -0,0 +1,92 @@
// 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 utils
import (
"bytes"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
// GetAbsolutePath returns the absolute path, e.g.:
// - ~/foo -> /home/user/foo
// - ./foo -> /current/dir/foo
// - /foo/ -> /foo
func GetAbsolutePath(path string) (newPath string, err error) {
if strings.HasPrefix(path, "~") {
newPath, err = homedir.Expand(path)
if err != nil {
return "", errors.Wrapf(err, "failed to expand path: %q", path)
}
} else {
newPath, err = filepath.Abs(path)
if err != nil {
return "", errors.Wrapf(err, "failed to get absolute path of %q", path)
}
}
l := len(newPath)
if l > 1 && newPath[l-1] == '/' { // if l == 1, the path might be "/"
newPath = newPath[:l-1]
}
return newPath, nil
}
// AddIndent for each line of str
func AddIndent(str, indent string) string {
ret := ""
ss := strings.Split(str, "\n")
for i, s := range ss {
if i == 0 {
ret = fmt.Sprintf("%s%s", indent, s)
} else {
ret = fmt.Sprintf("%s\n%s%s", ret, indent, s)
}
}
return ret
}
// MarshalYamlWithIndent marshals v to yaml with indent, specify space width with spaces
func MarshalYamlWithIndent(v interface{}, spaces int) ([]byte, error) {
w := new(bytes.Buffer)
ec := yaml.NewEncoder(w)
defer ec.Close()
ec.SetIndent(spaces)
if err := ec.Encode(v); err != nil {
return w.Bytes(), err
}
return w.Bytes(), nil
}
// MarshalYamlWithIndentTo marshals v to yaml with indent, specify space width with spaces, and output to w
func MarshalYamlWithIndentTo(w io.Writer, v interface{}, spaces int) error {
ec := yaml.NewEncoder(w)
defer ec.Close()
ec.SetIndent(spaces)
if err := ec.Encode(v); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,58 @@
// 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 utils
import (
"fmt"
"io"
)
type Debugger interface {
Debugf(format string, a ...any) (int, error)
Debugln(a ...any) (int, error)
}
type DefaultDebugger struct {
debug bool
w io.Writer
}
func NewDefaultDebugger(debug bool, w io.Writer) *DefaultDebugger {
return &DefaultDebugger{debug: debug, w: w}
}
func (d DefaultDebugger) Debugf(format string, a ...any) (int, error) {
l := len(format)
if l > 0 && format[l-1] != '\n' {
format += "\n"
}
if d.debug {
format = "[debug] " + format
return fmt.Fprintf(d.w, format, a...)
}
return 0, nil
}
func (d DefaultDebugger) Debugln(a ...any) (int, error) {
if d.debug {
n1, err1 := fmt.Fprintf(d.w, "[debug] ")
if err1 != nil {
return n1, err1
}
n2, err2 := fmt.Fprintln(d.w, a...)
return n1 + n2, err2
}
return 0, nil
}

View File

@@ -0,0 +1,165 @@
// 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 utils
import (
"fmt"
"io"
"os"
"strings"
"github.com/fatih/color"
)
type YesOrNoPrinter struct {
out io.Writer
indent *Indent
yes, no *color.Color
}
var (
DefaultOut = os.Stdout
DefaultIdent = NewIndent(strings.Repeat(" ", 2), 0)
DefaultYes = color.New(color.FgHiGreen)
DefaultNo = color.New(color.FgHiRed)
)
func NewPrinter(out io.Writer, indent *Indent, yes, no *color.Color) *YesOrNoPrinter {
return &YesOrNoPrinter{
out: out,
indent: indent,
yes: yes,
no: no,
}
}
func DefaultPrinter() *YesOrNoPrinter {
return NewPrinter(DefaultOut, DefaultIdent, DefaultYes, DefaultNo)
}
func (p *YesOrNoPrinter) Printf(format string, a ...interface{}) (int, error) {
return fmt.Fprintf(p.out, format, a...)
}
func (p *YesOrNoPrinter) Println(a ...interface{}) (int, error) {
return fmt.Fprintln(p.out, a...)
}
func (p *YesOrNoPrinter) PrintWithIndentf(format string, a ...interface{}) (int, error) {
format = fmt.Sprintf("%s%s", p.indent, format)
return fmt.Fprintf(p.out, format, a...)
}
func (p *YesOrNoPrinter) PrintWithIndentln(a ...interface{}) (int, error) {
n1, err := fmt.Fprintf(p.out, "%s", p.indent)
if err != nil {
return n1, err
}
n2, err := fmt.Fprintln(p.out, a...)
if err != nil {
return n1 + n2, err
}
return n1 + n2, nil
}
func (p *YesOrNoPrinter) Yesf(format string, a ...interface{}) (int, error) {
return p.yes.Fprintf(p.out, format, a...)
}
func (p *YesOrNoPrinter) Yesln(a ...interface{}) (int, error) {
return p.yes.Fprintln(p.out, a...)
}
func (p *YesOrNoPrinter) YesWithIndentf(format string, a ...interface{}) (int, error) {
format = fmt.Sprintf("%s%s", p.indent, format)
return p.yes.Fprintf(p.out, format, a...)
}
func (p *YesOrNoPrinter) YesWithIndentln(a ...interface{}) (int, error) {
n1, err := p.yes.Fprintf(p.out, "%s", p.indent)
if err != nil {
return n1, err
}
n2, err := p.yes.Fprintln(p.out, a...)
if err != nil {
return n1 + n2, err
}
return n1 + n2, nil
}
func (p *YesOrNoPrinter) Nof(format string, a ...interface{}) (int, error) {
return p.no.Fprintf(p.out, format, a...)
}
func (p *YesOrNoPrinter) Noln(a ...interface{}) (int, error) {
return p.no.Fprintln(p.out, a...)
}
func (p *YesOrNoPrinter) NoWithIndentf(format string, a ...interface{}) (int, error) {
format = fmt.Sprintf("%s%s", p.indent, format)
return p.no.Fprintf(p.out, format, a...)
}
func (p *YesOrNoPrinter) NoWithIndentln(a ...interface{}) (int, error) {
n1, err := p.no.Fprintf(p.out, "%s", p.indent)
if err != nil {
return n1, err
}
n2, err := p.no.Fprintln(p.out, a...)
if err != nil {
return n1 + n2, err
}
return n1 + n2, nil
}
func (p *YesOrNoPrinter) Ident() string { return p.indent.String() }
func (p *YesOrNoPrinter) IncIdentRepeat() { p.indent.IncRepeat() }
func (p *YesOrNoPrinter) DecIndentRepeat() { p.indent.DecRepeat() }
func (p *YesOrNoPrinter) SetIdentRepeat(v int) { p.indent.SetRepeat(v) }
type Indent struct {
format string
repeat int
}
func NewIndent(format string, repeat int) *Indent {
return &Indent{
format: format,
repeat: repeat,
}
}
func (i *Indent) String() string {
return strings.Repeat(i.format, i.repeat)
}
func (i *Indent) IncRepeat() { i.repeat++ }
func (i *Indent) DecRepeat() {
i.repeat--
if i.repeat < 0 {
i.repeat = 0
}
}
func (i *Indent) SetRepeat(v int) {
if v < 0 {
v = 0
}
i.repeat = v
}

View File

@@ -0,0 +1,31 @@
// 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 utils
import "github.com/AlecAivazis/survey/v2"
func Ask(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error {
opts = append(opts, survey.WithIcons(func(set *survey.IconSet) {
set.Error.Format = "red+hb"
}))
return survey.Ask(qs, response, opts...)
}
func AskOne(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
opts = append(opts, survey.WithIcons(func(set *survey.IconSet) {
set.Error.Format = "red+hb"
}))
return survey.AskOne(p, response, opts...)
}

View File

@@ -14,7 +14,10 @@
package hgctl
import "github.com/spf13/cobra"
import (
"github.com/alibaba/higress/pkg/cmd/hgctl/plugin"
"github.com/spf13/cobra"
)
// GetRootCommand returns the root cobra command to be executed
// by hgctl main.
@@ -34,6 +37,7 @@ func GetRootCommand() *cobra.Command {
rootCmd.AddCommand(newProfileCmd())
rootCmd.AddCommand(newDashboardCmd())
rootCmd.AddCommand(newManifestCmd())
rootCmd.AddCommand(plugin.NewCommand())
return rootCmd
}