istio.io/istio@v0.0.0-20240520182934-d79c90f27776/istioctl/pkg/workload/workload_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 workload
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"fmt"
    21  	"os"
    22  	"path"
    23  	"reflect"
    24  	"strings"
    25  	"testing"
    26  
    27  	"github.com/spf13/cobra"
    28  	v1 "k8s.io/api/core/v1"
    29  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    30  
    31  	"istio.io/istio/istioctl/pkg/cli"
    32  	"istio.io/istio/pilot/test/util"
    33  	"istio.io/istio/pkg/config/constants"
    34  	"istio.io/istio/pkg/kube"
    35  	"istio.io/istio/pkg/test/util/assert"
    36  )
    37  
    38  var fakeCACert = []byte("fake-CA-cert")
    39  
    40  var (
    41  	defaultYAML = `apiVersion: networking.istio.io/v1alpha3
    42  kind: WorkloadGroup
    43  metadata:
    44    name: foo
    45    namespace: bar
    46  spec:
    47    metadata: {}
    48    template:
    49      serviceAccount: default
    50  `
    51  
    52  	customYAML = `apiVersion: networking.istio.io/v1alpha3
    53  kind: WorkloadGroup
    54  metadata:
    55    name: foo
    56    namespace: bar
    57  spec:
    58    metadata:
    59      annotations:
    60        annotation: foobar
    61      labels:
    62        app: foo
    63        bar: baz
    64    template:
    65      ports:
    66        grpc: 3550
    67        http: 8080
    68      serviceAccount: test
    69  `
    70  )
    71  
    72  type testcase struct {
    73  	description       string
    74  	expectedException bool
    75  	args              []string
    76  	expectedOutput    string
    77  	namespace         string
    78  }
    79  
    80  func TestWorkloadGroupCreate(t *testing.T) {
    81  	cases := []testcase{
    82  		{
    83  			description:       "Invalid command args - missing service name and namespace",
    84  			args:              strings.Split("group create", " "),
    85  			expectedException: true,
    86  			expectedOutput:    "Error: expecting a workload name\n",
    87  		},
    88  		{
    89  			description:       "Invalid command args - missing service name",
    90  			args:              strings.Split("group create -n bar", " "),
    91  			expectedException: true,
    92  			expectedOutput:    "Error: expecting a workload name\n",
    93  		},
    94  		{
    95  			description:       "Invalid command args - missing service namespace",
    96  			args:              strings.Split("group create --name foo", " "),
    97  			expectedException: true,
    98  			expectedOutput:    "Error: expecting a workload namespace\n",
    99  		},
   100  		{
   101  			description:       "valid case - minimal flags, infer defaults",
   102  			args:              strings.Split("group create --name foo --namespace bar", " "),
   103  			expectedException: false,
   104  			expectedOutput:    defaultYAML,
   105  		},
   106  		{
   107  			description: "valid case - create full workload group",
   108  			args: strings.Split("group create --name foo --namespace bar --labels app=foo,bar=baz "+
   109  				" --annotations annotation=foobar --ports grpc=3550,http=8080 --serviceAccount test", " "),
   110  			expectedException: false,
   111  			expectedOutput:    customYAML,
   112  		},
   113  		{
   114  			description: "valid case - create full workload group with shortnames",
   115  			args: strings.Split("group create --name foo -n bar -l app=foo,bar=baz -p grpc=3550,http=8080"+
   116  				" -a annotation=foobar --serviceAccount test", " "),
   117  			expectedException: false,
   118  			expectedOutput:    customYAML,
   119  		},
   120  	}
   121  
   122  	for i, c := range cases {
   123  		t.Run(fmt.Sprintf("case %d %s", i, c.description), func(t *testing.T) {
   124  			verifyTestcaseOutput(t, Cmd(cli.NewFakeContext(nil)), c)
   125  		})
   126  	}
   127  }
   128  
   129  func verifyTestcaseOutput(t *testing.T, cmd *cobra.Command, c testcase) {
   130  	t.Helper()
   131  
   132  	var out bytes.Buffer
   133  	cmd.SetArgs(c.args)
   134  	cmd.SetOut(&out)
   135  	cmd.SetErr(&out)
   136  	cmd.SilenceUsage = true
   137  	if c.namespace != "" {
   138  		namespace = c.namespace
   139  	}
   140  
   141  	fErr := cmd.Execute()
   142  	output := out.String()
   143  
   144  	if c.expectedException {
   145  		if fErr == nil {
   146  			t.Fatalf("Wanted an exception, "+
   147  				"didn't get one, output was %q", output)
   148  		}
   149  	} else {
   150  		if fErr != nil {
   151  			t.Fatalf("Unwanted exception: %v", fErr)
   152  		}
   153  	}
   154  
   155  	if c.expectedOutput != "" && c.expectedOutput != output {
   156  		assert.Equal(t, c.expectedOutput, output)
   157  		t.Fatalf("Unexpected output for 'istioctl %s'\n got: %q\nwant: %q", strings.Join(c.args, " "), output, c.expectedOutput)
   158  	}
   159  }
   160  
   161  func TestWorkloadEntryConfigureInvalidArgs(t *testing.T) {
   162  	cases := []testcase{
   163  		{
   164  			description:       "Invalid command args - missing valid input spec",
   165  			args:              strings.Split("entry configure --name foo -o temp --clusterID cid", " "),
   166  			expectedException: true,
   167  			expectedOutput:    "Error: expecting a WorkloadGroup artifact file or the name and namespace of an existing WorkloadGroup\n",
   168  		},
   169  		{
   170  			description:       "Invalid command args - missing valid input spec",
   171  			args:              strings.Split("entry configure -n bar -o temp --clusterID cid", " "),
   172  			expectedException: true,
   173  			expectedOutput:    "Error: expecting a WorkloadGroup artifact file or the name and namespace of an existing WorkloadGroup\n",
   174  		},
   175  		{
   176  			description:       "Invalid command args - valid filename input but missing output filename",
   177  			args:              strings.Split("entry configure -f file --clusterID cid", " "),
   178  			expectedException: true,
   179  			expectedOutput:    "Error: expecting an output directory\n",
   180  		},
   181  		{
   182  			description:       "Invalid command args - valid kubectl input but missing output filename",
   183  			args:              strings.Split("entry configure --name foo -n bar --clusterID cid", " "),
   184  			expectedException: true,
   185  			expectedOutput:    "Error: expecting an output directory\n",
   186  		},
   187  	}
   188  
   189  	for i, c := range cases {
   190  		t.Run(fmt.Sprintf("case %d %s", i, c.description), func(t *testing.T) {
   191  			verifyTestcaseOutput(t, Cmd(cli.NewFakeContext(nil)), c)
   192  		})
   193  	}
   194  }
   195  
   196  var generated = map[string]bool{
   197  	"hosts":         true,
   198  	"istio-token":   true,
   199  	"mesh.yaml":     true,
   200  	"root-cert.pem": true,
   201  	"cluster.env":   true,
   202  }
   203  
   204  const goldenSuffix = ".golden"
   205  
   206  // TestWorkloadEntryConfigure enumerates test cases based on subdirectories of testdata/vmconfig.
   207  // Each subdirectory contains two input files: workloadgroup.yaml and meshconfig.yaml that are used
   208  // to generate golden outputs from the VM command.
   209  func TestWorkloadEntryConfigure(t *testing.T) {
   210  	noClusterID := "failed to automatically determine the --clusterID"
   211  	files, err := os.ReadDir("testdata/vmconfig")
   212  	if err != nil {
   213  		t.Fatal(err)
   214  	}
   215  	testCases := map[string]map[string]string{
   216  		"ipv4": {
   217  			"internalIP": "10.10.10.10",
   218  			"ingressIP":  "10.10.10.11",
   219  		},
   220  		"ipv6": {
   221  			"internalIP": "fd00:10:96::1",
   222  			"ingressIP":  "fd00:10:96::2",
   223  		},
   224  	}
   225  	for _, dir := range files {
   226  		if !dir.IsDir() {
   227  			continue
   228  		}
   229  		testdir := path.Join("testdata/vmconfig", dir.Name())
   230  		t.Cleanup(func() {
   231  			for k := range generated {
   232  				os.Remove(path.Join(testdir, k))
   233  			}
   234  		})
   235  		t.Run(dir.Name(), func(t *testing.T) {
   236  			createClientFunc := func(client kube.CLIClient) {
   237  				client.Kube().CoreV1().ServiceAccounts("bar").Create(context.Background(), &v1.ServiceAccount{
   238  					ObjectMeta: metav1.ObjectMeta{Namespace: "bar", Name: "vm-serviceaccount"},
   239  					Secrets:    []v1.ObjectReference{{Name: "test"}},
   240  				}, metav1.CreateOptions{})
   241  				client.Kube().CoreV1().ConfigMaps("bar").Create(context.Background(), &v1.ConfigMap{
   242  					ObjectMeta: metav1.ObjectMeta{Namespace: "bar", Name: "istio-ca-root-cert"},
   243  					Data:       map[string]string{"root-cert.pem": string(fakeCACert)},
   244  				}, metav1.CreateOptions{})
   245  				client.Kube().CoreV1().ConfigMaps("istio-system").Create(context.Background(), &v1.ConfigMap{
   246  					ObjectMeta: metav1.ObjectMeta{Namespace: "istio-system", Name: "istio-rev-1"},
   247  					Data: map[string]string{
   248  						"mesh": string(util.ReadFile(t, path.Join(testdir, "meshconfig.yaml"))),
   249  					},
   250  				}, metav1.CreateOptions{})
   251  				client.Kube().CoreV1().Secrets("bar").Create(context.Background(), &v1.Secret{
   252  					ObjectMeta: metav1.ObjectMeta{Namespace: "bar", Name: "test"},
   253  					Data: map[string][]byte{
   254  						"token": {},
   255  					},
   256  				}, metav1.CreateOptions{})
   257  			}
   258  
   259  			cmdWithClusterID := []string{
   260  				"entry", "configure",
   261  				"-f", path.Join("testdata/vmconfig", dir.Name(), "workloadgroup.yaml"),
   262  				"--internalIP", testCases[dir.Name()]["internalIP"],
   263  				"--ingressIP", testCases[dir.Name()]["ingressIP"],
   264  				"--clusterID", constants.DefaultClusterName,
   265  				"--revision", "rev-1",
   266  				"-o", testdir,
   267  			}
   268  			if _, err := runTestCmd(t, createClientFunc, "rev-1", cmdWithClusterID); err != nil {
   269  				t.Fatal(err)
   270  			}
   271  
   272  			cmdNoClusterID := []string{
   273  				"entry", "configure",
   274  				"-f", path.Join("testdata/vmconfig", dir.Name(), "workloadgroup.yaml"),
   275  				"--internalIP", testCases[dir.Name()]["internalIP"],
   276  				"--revision", "rev-1",
   277  				"-o", testdir,
   278  			}
   279  			if output, err := runTestCmd(t, createClientFunc, "rev-1", cmdNoClusterID); err != nil {
   280  				if !strings.Contains(output, noClusterID) {
   281  					t.Fatal(err)
   282  				}
   283  			}
   284  
   285  			checkFiles := map[string]bool{
   286  				// inputs that we allow to exist, if other files seep in unexpectedly we fail the test
   287  				".gitignore": false, "meshconfig.yaml": false, "workloadgroup.yaml": false,
   288  			}
   289  			for k, v := range generated {
   290  				checkFiles[k] = v
   291  			}
   292  
   293  			checkOutputFiles(t, testdir, checkFiles)
   294  		})
   295  	}
   296  }
   297  
   298  func TestWorkloadEntryToPodPortsMeta(t *testing.T) {
   299  	cases := []struct {
   300  		description string
   301  		ports       map[string]uint32
   302  		want        string
   303  	}{
   304  		{
   305  			description: "test json marshal",
   306  			ports: map[string]uint32{
   307  				"HTTP":  80,
   308  				"HTTPS": 443,
   309  			},
   310  			want: `[{"name":"HTTP","containerPort":80,"protocol":""},{"name":"HTTPS","containerPort":443,"protocol":""}]`,
   311  		},
   312  	}
   313  	for i, c := range cases {
   314  		t.Run(fmt.Sprintf("case %d %s", i, c.description), func(t *testing.T) {
   315  			str := marshalWorkloadEntryPodPorts(c.ports)
   316  			if c.want != str {
   317  				t.Errorf("want %s, got %s", c.want, str)
   318  			}
   319  		})
   320  	}
   321  }
   322  
   323  // TestWorkloadEntryConfigureNilProxyMetadata tests a particular use case when the
   324  // proxyMetadata is nil, no metadata would be generated at all.
   325  func TestWorkloadEntryConfigureNilProxyMetadata(t *testing.T) {
   326  	testdir := "testdata/vmconfig-nil-proxy-metadata"
   327  	noClusterID := "failed to automatically determine the --clusterID"
   328  
   329  	t.Cleanup(func() {
   330  		for k := range generated {
   331  			os.Remove(path.Join(testdir, k))
   332  		}
   333  	})
   334  
   335  	createClientFunc := func(client kube.CLIClient) {
   336  		client.Kube().CoreV1().ServiceAccounts("bar").Create(context.Background(), &v1.ServiceAccount{
   337  			ObjectMeta: metav1.ObjectMeta{Namespace: "bar", Name: "vm-serviceaccount"},
   338  			Secrets:    []v1.ObjectReference{{Name: "test"}},
   339  		}, metav1.CreateOptions{})
   340  		client.Kube().CoreV1().ConfigMaps("bar").Create(context.Background(), &v1.ConfigMap{
   341  			ObjectMeta: metav1.ObjectMeta{Namespace: "bar", Name: "istio-ca-root-cert"},
   342  			Data:       map[string]string{"root-cert.pem": string(fakeCACert)},
   343  		}, metav1.CreateOptions{})
   344  		client.Kube().CoreV1().ConfigMaps("istio-system").Create(context.Background(), &v1.ConfigMap{
   345  			ObjectMeta: metav1.ObjectMeta{Namespace: "istio-system", Name: "istio"},
   346  			Data: map[string]string{
   347  				"mesh": "defaultConfig: {}",
   348  			},
   349  		}, metav1.CreateOptions{})
   350  		client.Kube().CoreV1().Secrets("bar").Create(context.Background(), &v1.Secret{
   351  			ObjectMeta: metav1.ObjectMeta{Namespace: "bar", Name: "test"},
   352  			Data: map[string][]byte{
   353  				"token": {},
   354  			},
   355  		}, metav1.CreateOptions{})
   356  	}
   357  
   358  	cmdWithClusterID := []string{
   359  		"entry", "configure",
   360  		"-f", path.Join(testdir, "workloadgroup.yaml"),
   361  		"--internalIP", "10.10.10.10",
   362  		"--clusterID", constants.DefaultClusterName,
   363  		"-o", testdir,
   364  	}
   365  	if output, err := runTestCmd(t, createClientFunc, "", cmdWithClusterID); err != nil {
   366  		t.Logf("output: %v", output)
   367  		t.Fatal(err)
   368  	}
   369  
   370  	cmdNoClusterID := []string{
   371  		"entry", "configure",
   372  		"-f", path.Join(testdir, "workloadgroup.yaml"),
   373  		"--internalIP", "10.10.10.10",
   374  		"-o", testdir,
   375  	}
   376  	if output, err := runTestCmd(t, createClientFunc, "", cmdNoClusterID); err != nil {
   377  		if !strings.Contains(output, noClusterID) {
   378  			t.Fatal(err)
   379  		}
   380  	}
   381  
   382  	checkFiles := map[string]bool{
   383  		// inputs that we allow to exist, if other files seep in unexpectedly we fail the test
   384  		".gitignore": false, "workloadgroup.yaml": false,
   385  	}
   386  	for k, v := range generated {
   387  		checkFiles[k] = v
   388  	}
   389  
   390  	checkOutputFiles(t, testdir, checkFiles)
   391  }
   392  
   393  func runTestCmd(t *testing.T, createResourceFunc func(client kube.CLIClient), rev string, args []string) (string, error) {
   394  	t.Helper()
   395  	// TODO there is already probably something else that does this
   396  	var out bytes.Buffer
   397  	ctx := cli.NewFakeContext(&cli.NewFakeContextOption{
   398  		IstioNamespace: "istio-system",
   399  	})
   400  	rootCmd := Cmd(ctx)
   401  	rootCmd.SetArgs(args)
   402  	client, err := ctx.CLIClientWithRevision(rev)
   403  	if err != nil {
   404  		return "", err
   405  	}
   406  	createResourceFunc(client)
   407  
   408  	rootCmd.SetOut(&out)
   409  	rootCmd.SetErr(&out)
   410  	err = rootCmd.Execute()
   411  	output := out.String()
   412  	return output, err
   413  }
   414  
   415  func checkOutputFiles(t *testing.T, testdir string, checkFiles map[string]bool) {
   416  	t.Helper()
   417  
   418  	outputFiles, err := os.ReadDir(testdir)
   419  	if err != nil {
   420  		t.Fatal(err)
   421  	}
   422  
   423  	for _, f := range outputFiles {
   424  		checkGolden, ok := checkFiles[f.Name()]
   425  		if !ok {
   426  			if checkGolden, ok := checkFiles[f.Name()[:len(f.Name())-len(goldenSuffix)]]; !(checkGolden && ok) {
   427  				t.Errorf("unexpected file in output dir: %s", f.Name())
   428  			}
   429  			continue
   430  		}
   431  		if checkGolden {
   432  			t.Run(f.Name(), func(t *testing.T) {
   433  				contents := util.ReadFile(t, path.Join(testdir, f.Name()))
   434  				goldenFile := path.Join(testdir, f.Name()+goldenSuffix)
   435  				util.RefreshGoldenFile(t, contents, goldenFile)
   436  				util.CompareContent(t, contents, goldenFile)
   437  			})
   438  		}
   439  	}
   440  }
   441  
   442  func TestConvertToMap(t *testing.T) {
   443  	tests := []struct {
   444  		name string
   445  		arg  []string
   446  		want map[string]string
   447  	}{
   448  		{name: "empty", arg: []string{""}, want: map[string]string{"": ""}},
   449  		{name: "one-valid", arg: []string{"key=value"}, want: map[string]string{"key": "value"}},
   450  		{name: "one-valid-double-equals", arg: []string{"key==value"}, want: map[string]string{"key": "=value"}},
   451  		{name: "one-key-only", arg: []string{"key"}, want: map[string]string{"key": ""}},
   452  	}
   453  	for _, tt := range tests {
   454  		t.Run(tt.name, func(t *testing.T) {
   455  			if got := convertToStringMap(tt.arg); !reflect.DeepEqual(got, tt.want) {
   456  				t.Errorf("convertToStringMap() = %v, want %v", got, tt.want)
   457  			}
   458  		})
   459  	}
   460  }
   461  
   462  func TestSplitEqual(t *testing.T) {
   463  	tests := []struct {
   464  		arg       string
   465  		wantKey   string
   466  		wantValue string
   467  	}{
   468  		{arg: "key=value", wantKey: "key", wantValue: "value"},
   469  		{arg: "key==value", wantKey: "key", wantValue: "=value"},
   470  		{arg: "key=", wantKey: "key", wantValue: ""},
   471  		{arg: "key", wantKey: "key", wantValue: ""},
   472  		{arg: "", wantKey: "", wantValue: ""},
   473  	}
   474  	for _, tt := range tests {
   475  		t.Run(tt.arg, func(t *testing.T) {
   476  			gotKey, gotValue := splitEqual(tt.arg)
   477  			if gotKey != tt.wantKey {
   478  				t.Errorf("splitEqual(%v) got = %v, want %v", tt.arg, gotKey, tt.wantKey)
   479  			}
   480  			if gotValue != tt.wantValue {
   481  				t.Errorf("splitEqual(%v) got1 = %v, want %v", tt.arg, gotValue, tt.wantValue)
   482  			}
   483  		})
   484  	}
   485  }