istio.io/istio@v0.0.0-20240520182934-d79c90f27776/pkg/wasm/convert_test.go (about)

     1  // Copyright Istio Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package wasm
    16  
    17  import (
    18  	"errors"
    19  	"fmt"
    20  	"net/url"
    21  	"reflect"
    22  	"testing"
    23  
    24  	udpa "github.com/cncf/xds/go/udpa/type/v1"
    25  	core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
    26  	rbac "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3"
    27  	wasm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/wasm/v3"
    28  	v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/wasm/v3"
    29  	"github.com/envoyproxy/go-control-plane/pkg/conversion"
    30  	resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3"
    31  	"google.golang.org/protobuf/proto"
    32  	anypb "google.golang.org/protobuf/types/known/anypb"
    33  	"google.golang.org/protobuf/types/known/emptypb"
    34  	"google.golang.org/protobuf/types/known/structpb"
    35  
    36  	"istio.io/istio/pilot/pkg/model"
    37  	"istio.io/istio/pilot/pkg/util/protoconv"
    38  	"istio.io/istio/pkg/config/xds"
    39  )
    40  
    41  type mockCache struct {
    42  	wantSecret []byte
    43  	wantPolicy PullPolicy
    44  }
    45  
    46  func (c *mockCache) Get(downloadURL string, opts GetOptions) (string, error) {
    47  	url, _ := url.Parse(downloadURL)
    48  	query := url.Query()
    49  
    50  	module := query.Get("module")
    51  	errMsg := query.Get("error")
    52  	var err error
    53  	if errMsg != "" {
    54  		err = errors.New(errMsg)
    55  	}
    56  	if c.wantSecret != nil && !reflect.DeepEqual(c.wantSecret, opts.PullSecret) {
    57  		return "", fmt.Errorf("wrong secret for %v, got %q want %q", downloadURL, string(opts.PullSecret), c.wantSecret)
    58  	}
    59  	if c.wantPolicy != opts.PullPolicy {
    60  		return "", fmt.Errorf("wrong pull policy for %v, got %v want %v", downloadURL, opts.PullPolicy, c.wantPolicy)
    61  	}
    62  
    63  	return module, err
    64  }
    65  func (c *mockCache) Cleanup() {}
    66  
    67  func messageToStruct(t *testing.T, m proto.Message) *structpb.Struct {
    68  	st, err := conversion.MessageToStruct(m)
    69  	if err != nil {
    70  		t.Fatal(err)
    71  	}
    72  	return st
    73  }
    74  
    75  func newStruct(t *testing.T, m map[string]any) *structpb.Struct {
    76  	st, err := structpb.NewStruct(m)
    77  	if err != nil {
    78  		t.Fatal(err)
    79  	}
    80  	return st
    81  }
    82  
    83  func messageToAnyWithTypeURL(t *testing.T, msg proto.Message, typeURL string) *anypb.Any {
    84  	b, err := proto.MarshalOptions{Deterministic: true}.Marshal(msg)
    85  	if err != nil {
    86  		t.Fatal(err)
    87  	}
    88  	return &anypb.Any{
    89  		// nolint: staticcheck
    90  		TypeUrl: typeURL,
    91  		Value:   b,
    92  	}
    93  }
    94  
    95  func TestWasmConvertWithWrongMessages(t *testing.T) {
    96  	cases := []struct {
    97  		name     string
    98  		input    []*anypb.Any
    99  		wantNack bool
   100  	}{
   101  		{
   102  			name:     "wrong typed config",
   103  			input:    []*anypb.Any{protoconv.MessageToAny(&emptypb.Empty{})},
   104  			wantNack: true,
   105  		},
   106  		{
   107  			name: "wrong value in typed struct",
   108  			input: []*anypb.Any{protoconv.MessageToAny(&core.TypedExtensionConfig{
   109  				Name: "wrong-input",
   110  				TypedConfig: protoconv.MessageToAny(
   111  					&udpa.TypedStruct{
   112  						TypeUrl: xds.WasmHTTPFilterType,
   113  						Value:   newStruct(t, map[string]any{"wrong": "value"}),
   114  					},
   115  				),
   116  			})},
   117  			wantNack: true,
   118  		},
   119  		{
   120  			name: "empty wasm in typed struct",
   121  			input: []*anypb.Any{protoconv.MessageToAny(&core.TypedExtensionConfig{
   122  				Name: "wrong-input",
   123  				TypedConfig: protoconv.MessageToAny(
   124  					&udpa.TypedStruct{
   125  						TypeUrl: xds.WasmHTTPFilterType,
   126  						Value:   messageToStruct(t, &wasm.Wasm{}),
   127  					},
   128  				),
   129  			})},
   130  			wantNack: true,
   131  		},
   132  		{
   133  			name: "no remote and local code in wasm",
   134  			input: []*anypb.Any{protoconv.MessageToAny(&core.TypedExtensionConfig{
   135  				Name: "wrong-input",
   136  				TypedConfig: protoconv.MessageToAny(
   137  					&udpa.TypedStruct{
   138  						TypeUrl: xds.WasmHTTPFilterType,
   139  						Value: messageToStruct(t, &wasm.Wasm{
   140  							Config: &v3.PluginConfig{
   141  								Vm: &v3.PluginConfig_VmConfig{
   142  									VmConfig: &v3.VmConfig{
   143  										Code: &core.AsyncDataSource{},
   144  									},
   145  								},
   146  							},
   147  						}),
   148  					},
   149  				),
   150  			})},
   151  			wantNack: true,
   152  		},
   153  		{
   154  			name: "empty wasm in typed extension config",
   155  			input: []*anypb.Any{protoconv.MessageToAny(&core.TypedExtensionConfig{
   156  				Name:        "wrong-input",
   157  				TypedConfig: protoconv.MessageToAny(&wasm.Wasm{}),
   158  			})},
   159  			wantNack: true,
   160  		},
   161  		{
   162  			name: "wrong wasm filter value",
   163  			input: []*anypb.Any{protoconv.MessageToAny(&core.TypedExtensionConfig{
   164  				Name:        "wrong-input",
   165  				TypedConfig: messageToAnyWithTypeURL(t, &v3.VmConfig{Runtime: "test", VmId: "test"}, xds.WasmHTTPFilterType),
   166  			})},
   167  			wantNack: true,
   168  		},
   169  		{
   170  			name: "wrong typed struct value",
   171  			input: []*anypb.Any{protoconv.MessageToAny(&core.TypedExtensionConfig{
   172  				Name:        "wrong-input",
   173  				TypedConfig: messageToAnyWithTypeURL(t, &v3.VmConfig{Runtime: "test", VmId: "test"}, xds.TypedStructType),
   174  			})},
   175  			wantNack: true,
   176  		},
   177  	}
   178  
   179  	for _, tc := range cases {
   180  		t.Run(tc.name, func(t *testing.T) {
   181  			mc := &mockCache{}
   182  			gotErr := MaybeConvertWasmExtensionConfig(tc.input, mc)
   183  			if gotErr == nil {
   184  				t.Errorf("wasm config conversion should return error, but did not")
   185  			}
   186  		})
   187  	}
   188  }
   189  
   190  func TestWasmConvert(t *testing.T) {
   191  	cases := []struct {
   192  		name       string
   193  		input      []*core.TypedExtensionConfig
   194  		wantOutput []*core.TypedExtensionConfig
   195  		wantErr    bool
   196  	}{
   197  		{
   198  			name: "nil typed config ",
   199  			input: []*core.TypedExtensionConfig{
   200  				extensionConfigMap["nil-typed-config"],
   201  			},
   202  			wantOutput: []*core.TypedExtensionConfig{
   203  				extensionConfigMap["nil-typed-config"],
   204  			},
   205  			wantErr: true,
   206  		},
   207  		{
   208  			name: "remote load success",
   209  			input: []*core.TypedExtensionConfig{
   210  				extensionConfigMap["remote-load-success"],
   211  			},
   212  			wantOutput: []*core.TypedExtensionConfig{
   213  				extensionConfigMap["remote-load-success-local-file"],
   214  			},
   215  			wantErr: false,
   216  		},
   217  		{
   218  			name: "remote load success without typed struct",
   219  			input: []*core.TypedExtensionConfig{
   220  				extensionConfigMap["remote-load-success-without-typed-struct"],
   221  			},
   222  			wantOutput: []*core.TypedExtensionConfig{
   223  				extensionConfigMap["remote-load-success-local-file"],
   224  			},
   225  			wantErr: false,
   226  		},
   227  		{
   228  			name: "remote load fail",
   229  			input: []*core.TypedExtensionConfig{
   230  				extensionConfigMap["remote-load-fail"],
   231  			},
   232  			wantOutput: []*core.TypedExtensionConfig{
   233  				extensionConfigMap["remote-load-fail"],
   234  			},
   235  			wantErr: true,
   236  		},
   237  		{
   238  			name: "mix",
   239  			input: []*core.TypedExtensionConfig{
   240  				extensionConfigMap["remote-load-fail"],
   241  				extensionConfigMap["remote-load-success"],
   242  			},
   243  			wantOutput: []*core.TypedExtensionConfig{
   244  				extensionConfigMap["remote-load-fail"],
   245  				extensionConfigMap["remote-load-success-local-file"],
   246  			},
   247  			wantErr: true,
   248  		},
   249  		{
   250  			name: "remote load fail open",
   251  			input: []*core.TypedExtensionConfig{
   252  				extensionConfigMap["remote-load-fail-open"],
   253  			},
   254  			wantOutput: []*core.TypedExtensionConfig{
   255  				extensionConfigMap["remote-load-allow"],
   256  			},
   257  			wantErr: false,
   258  		},
   259  		{
   260  			name: "no typed struct",
   261  			input: []*core.TypedExtensionConfig{
   262  				extensionConfigMap["empty"],
   263  			},
   264  			wantOutput: []*core.TypedExtensionConfig{
   265  				extensionConfigMap["empty"],
   266  			},
   267  			wantErr: false,
   268  		},
   269  		{
   270  			name: "no wasm",
   271  			input: []*core.TypedExtensionConfig{
   272  				extensionConfigMap["no-wasm"],
   273  			},
   274  			wantOutput: []*core.TypedExtensionConfig{
   275  				extensionConfigMap["no-wasm"],
   276  			},
   277  			wantErr: false,
   278  		},
   279  		{
   280  			name: "no remote load",
   281  			input: []*core.TypedExtensionConfig{
   282  				extensionConfigMap["no-remote-load"],
   283  			},
   284  			wantOutput: []*core.TypedExtensionConfig{
   285  				extensionConfigMap["no-remote-load"],
   286  			},
   287  			wantErr: false,
   288  		},
   289  		{
   290  			name: "no uri",
   291  			input: []*core.TypedExtensionConfig{
   292  				extensionConfigMap["no-http-uri"],
   293  			},
   294  			wantOutput: []*core.TypedExtensionConfig{
   295  				extensionConfigMap["no-http-uri"],
   296  			},
   297  			wantErr: true,
   298  		},
   299  		{
   300  			name: "secret",
   301  			input: []*core.TypedExtensionConfig{
   302  				extensionConfigMap["remote-load-secret"],
   303  			},
   304  			wantOutput: []*core.TypedExtensionConfig{
   305  				extensionConfigMap["remote-load-success-local-file"],
   306  			},
   307  			wantErr: false,
   308  		},
   309  	}
   310  
   311  	for _, c := range cases {
   312  		t.Run(c.name, func(t *testing.T) {
   313  			resources := make([]*anypb.Any, 0, len(c.input))
   314  			for _, i := range c.input {
   315  				resources = append(resources, protoconv.MessageToAny(i))
   316  			}
   317  			mc := &mockCache{}
   318  			gotErr := MaybeConvertWasmExtensionConfig(resources, mc)
   319  			if len(resources) != len(c.wantOutput) {
   320  				t.Fatalf("wasm config conversion number of configuration got %v want %v", len(resources), len(c.wantOutput))
   321  			}
   322  			for i, output := range resources {
   323  				ec := &core.TypedExtensionConfig{}
   324  				if err := output.UnmarshalTo(ec); err != nil {
   325  					t.Errorf("wasm config conversion output %v failed to unmarshal", output)
   326  					continue
   327  				}
   328  				if !proto.Equal(ec, c.wantOutput[i]) {
   329  					t.Errorf("wasm config conversion output index %d got %v want %v", i, ec, c.wantOutput[i])
   330  				}
   331  			}
   332  			if c.wantErr && gotErr == nil {
   333  				t.Error("wasm config conversion fails to raise an error")
   334  			} else if !c.wantErr && gotErr != nil {
   335  				t.Errorf("wasm config conversion got unexpected error: %v", gotErr)
   336  			}
   337  		})
   338  	}
   339  }
   340  
   341  func buildTypedStructExtensionConfig(name string, wasm *wasm.Wasm) *core.TypedExtensionConfig {
   342  	ws, _ := conversion.MessageToStruct(wasm)
   343  	return &core.TypedExtensionConfig{
   344  		Name: name,
   345  		TypedConfig: protoconv.MessageToAny(
   346  			&udpa.TypedStruct{
   347  				TypeUrl: xds.WasmHTTPFilterType,
   348  				Value:   ws,
   349  			},
   350  		),
   351  	}
   352  }
   353  
   354  func buildAnyExtensionConfig(name string, msg proto.Message) *core.TypedExtensionConfig {
   355  	return &core.TypedExtensionConfig{
   356  		Name:        name,
   357  		TypedConfig: protoconv.MessageToAny(msg),
   358  	}
   359  }
   360  
   361  var extensionConfigMap = map[string]*core.TypedExtensionConfig{
   362  	"nil-typed-config": {
   363  		Name:        "nil-typed-config",
   364  		TypedConfig: nil,
   365  	},
   366  	"empty": {
   367  		Name: "empty",
   368  		TypedConfig: protoconv.MessageToAny(
   369  			&structpb.Struct{},
   370  		),
   371  	},
   372  	"no-wasm": {
   373  		Name: "no-wasm",
   374  		TypedConfig: protoconv.MessageToAny(
   375  			&udpa.TypedStruct{TypeUrl: resource.APITypePrefix + "sometype"},
   376  		),
   377  	},
   378  	"no-remote-load": buildTypedStructExtensionConfig("no-remote-load", &wasm.Wasm{
   379  		Config: &v3.PluginConfig{
   380  			Vm: &v3.PluginConfig_VmConfig{
   381  				VmConfig: &v3.VmConfig{
   382  					Runtime: "envoy.wasm.runtime.null",
   383  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Local{
   384  						Local: &core.DataSource{
   385  							Specifier: &core.DataSource_InlineString{
   386  								InlineString: "envoy.wasm.metadata_exchange",
   387  							},
   388  						},
   389  					}},
   390  				},
   391  			},
   392  		},
   393  	}),
   394  	"no-http-uri": buildTypedStructExtensionConfig("no-remote-load", &wasm.Wasm{
   395  		Config: &v3.PluginConfig{
   396  			Vm: &v3.PluginConfig_VmConfig{
   397  				VmConfig: &v3.VmConfig{
   398  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Remote{
   399  						Remote: &core.RemoteDataSource{},
   400  					}},
   401  				},
   402  			},
   403  		},
   404  	}),
   405  	"remote-load-success": buildTypedStructExtensionConfig("remote-load-success", &wasm.Wasm{
   406  		Config: &v3.PluginConfig{
   407  			Vm: &v3.PluginConfig_VmConfig{
   408  				VmConfig: &v3.VmConfig{
   409  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Remote{
   410  						Remote: &core.RemoteDataSource{
   411  							HttpUri: &core.HttpUri{
   412  								Uri: "http://test?module=test.wasm",
   413  							},
   414  						},
   415  					}},
   416  				},
   417  			},
   418  		},
   419  	}),
   420  	"remote-load-success-local-file": buildAnyExtensionConfig("remote-load-success", &wasm.Wasm{
   421  		Config: &v3.PluginConfig{
   422  			Vm: &v3.PluginConfig_VmConfig{
   423  				VmConfig: &v3.VmConfig{
   424  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Local{
   425  						Local: &core.DataSource{
   426  							Specifier: &core.DataSource_Filename{
   427  								Filename: "test.wasm",
   428  							},
   429  						},
   430  					}},
   431  				},
   432  			},
   433  		},
   434  	}),
   435  	"remote-load-success-without-typed-struct": buildAnyExtensionConfig("remote-load-success", &wasm.Wasm{
   436  		Config: &v3.PluginConfig{
   437  			Vm: &v3.PluginConfig_VmConfig{
   438  				VmConfig: &v3.VmConfig{
   439  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Remote{
   440  						Remote: &core.RemoteDataSource{
   441  							HttpUri: &core.HttpUri{
   442  								Uri: "http://test?module=test.wasm",
   443  							},
   444  						},
   445  					}},
   446  				},
   447  			},
   448  		},
   449  	}),
   450  	"remote-load-fail": buildTypedStructExtensionConfig("remote-load-fail", &wasm.Wasm{
   451  		Config: &v3.PluginConfig{
   452  			Vm: &v3.PluginConfig_VmConfig{
   453  				VmConfig: &v3.VmConfig{
   454  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Remote{
   455  						Remote: &core.RemoteDataSource{
   456  							HttpUri: &core.HttpUri{
   457  								Uri: "http://test?module=test.wasm&error=download-error",
   458  							},
   459  						},
   460  					}},
   461  				},
   462  			},
   463  		},
   464  	}),
   465  	"remote-load-fail-open": buildTypedStructExtensionConfig("remote-load-fail", &wasm.Wasm{
   466  		Config: &v3.PluginConfig{
   467  			Vm: &v3.PluginConfig_VmConfig{
   468  				VmConfig: &v3.VmConfig{
   469  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Remote{
   470  						Remote: &core.RemoteDataSource{
   471  							HttpUri: &core.HttpUri{
   472  								Uri: "http://test?module=test.wasm&error=download-error",
   473  							},
   474  						},
   475  					}},
   476  				},
   477  			},
   478  			FailOpen: true,
   479  		},
   480  	}),
   481  	"remote-load-allow": buildAnyExtensionConfig("remote-load-fail", &rbac.RBAC{}),
   482  	"remote-load-secret": buildTypedStructExtensionConfig("remote-load-success", &wasm.Wasm{
   483  		Config: &v3.PluginConfig{
   484  			Vm: &v3.PluginConfig_VmConfig{
   485  				VmConfig: &v3.VmConfig{
   486  					Code: &core.AsyncDataSource{Specifier: &core.AsyncDataSource_Remote{
   487  						Remote: &core.RemoteDataSource{
   488  							HttpUri: &core.HttpUri{
   489  								Uri: "http://test?module=test.wasm",
   490  							},
   491  						},
   492  					}},
   493  					EnvironmentVariables: &v3.EnvironmentVariables{
   494  						KeyValues: map[string]string{
   495  							model.WasmSecretEnv: "secret",
   496  						},
   497  					},
   498  				},
   499  			},
   500  		},
   501  	}),
   502  }