//go:build integration // +build integration package functions_test import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "reflect" "testing" "time" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" "knative.dev/func/pkg/builders/buildpacks" "knative.dev/func/pkg/builders/s2i" "knative.dev/func/pkg/docker" fn "knative.dev/func/pkg/functions" "knative.dev/func/pkg/knative" . "knative.dev/func/pkg/testing" "knative.dev/pkg/ptr" ) // # Integration Tests // // go test -tags integration ./... // // ## Cluster Required // // These integration tests require a properly configured cluster, // such as that which is setup and configured in CI (see .github/workflows). // Linux developers can set up the cluster via: // // ./hack/install-binaries.sh && ./hack/allocate.sh && ./hack/registry.sh // // ## Cluster Cleanup // // The test cluster and most resources can be removed with: // ./hack/delete.sh // // NOTE: Downloaded images are not removed. // const ( // DefaultRegistry must contain both the registry host and // registry namespace at this time. This will likely be // split and defaulted to the forthcoming in-cluster registry. DefaultRegistry = "localhost:50000/func" // DefaultNamespace for the underlying deployments. Must be the same // as is set up and configured (see hack/configure.sh) DefaultNamespace = "func" ) func TestList(t *testing.T) { verbose := true // Assemble lister := knative.NewLister(verbose) client := fn.New( fn.WithLister(lister), fn.WithVerbose(verbose)) // Act names, err := client.List(context.Background(), DefaultNamespace) if err != nil { t.Fatal(err) } // Assert if len(names) != 0 { t.Fatalf("Expected no functions, got %v", names) } } // TestNew creates func TestNew(t *testing.T) { // Assemble root, cleanup := Mktemp(t) defer cleanup() verbose := true name := "test-new" client := newClient(verbose) // Act if _, _, err := client.New(context.Background(), fn.Function{Name: name, Namespace: DefaultNamespace, Root: root, Runtime: "go"}); err != nil { t.Fatal(err) } defer del(t, client, name, DefaultNamespace) // Assert items, err := client.List(context.Background(), DefaultNamespace) names := []string{} for _, item := range items { names = append(names, item.Name) } if err != nil { t.Fatal(err) } if !reflect.DeepEqual(names, []string{name}) { t.Fatalf("Expected function list ['%v'], got %v", name, names) } } // TestDeploy_Defaults deployes using client methods from New but manually func TestDeploy_Defaults(t *testing.T) { defer Within(t, "testdata/example.com/deploy")() verbose := true client := newClient(verbose) f := fn.Function{Name: "deploy", Namespace: DefaultNamespace, Root: ".", Runtime: "go"} var err error if f, err = client.Init(f); err != nil { t.Fatal(err) } if f, err = client.Build(context.Background(), f); err != nil { t.Fatal(err) } if f, _, err = client.Push(context.Background(), f); err != nil { t.Fatal(err) } defer del(t, client, "deploy", DefaultNamespace) // TODO: gauron99 -- remove this when you set full image name after build instead // of push -- this has to be here because of a workaround f.Deploy.Image = f.Build.Image if f, err = client.Deploy(context.Background(), f); err != nil { t.Fatal(err) } } // TestDeploy_WithOptions deploys function with all options explicitly set func TestDeploy_WithOptions(t *testing.T) { root, cleanup := Mktemp(t) defer cleanup() verbose := false f := fn.Function{Runtime: "go", Name: "test-deploy-with-options", Root: root, Namespace: DefaultNamespace} f.Deploy = fn.DeploySpec{ Options: fn.Options{ Scale: &fn.ScaleOptions{ Min: ptr.Int64(1), Max: ptr.Int64(10), Metric: ptr.String("concurrency"), Target: ptr.Float64(5), Utilization: ptr.Float64(5), }, Resources: &fn.ResourcesOptions{ Requests: &fn.ResourcesRequestsOptions{ CPU: ptr.String("10m"), Memory: ptr.String("100m"), }, Limits: &fn.ResourcesLimitsOptions{ CPU: ptr.String("1000m"), Memory: ptr.String("1000M"), Concurrency: ptr.Int64(10), }, }, }, } client := newClient(verbose) if _, _, err := client.New(context.Background(), f); err != nil { t.Fatal(err) } defer del(t, client, "test-deploy-with-options", DefaultNamespace) } func TestDeployWithTriggers(t *testing.T) { root, cleanup := Mktemp(t) defer cleanup() verbose := true f := fn.Function{Runtime: "go", Name: "test-deploy-with-triggers", Root: root, Namespace: DefaultNamespace} f.Deploy = fn.DeploySpec{ Subscriptions: []fn.KnativeSubscription{ { Source: "default", Filters: map[string]string{ "key": "value", "foo": "bar", }, }, }, } client := newClient(verbose) if _, _, err := client.New(context.Background(), f); err != nil { t.Fatal(err) } defer del(t, client, "test-deploy-with-triggers", DefaultNamespace) } func TestUpdateWithAnnotationsAndLabels(t *testing.T) { functionName := "updateannlab" defer Within(t, "testdata/example.com/"+functionName)() verbose := false servingClient, err := knative.NewServingClient(DefaultNamespace) if err != nil { t.Fatal(err) } // Deploy a function without any annotations or labels client := newClient(verbose) f := fn.Function{Name: functionName, Root: ".", Runtime: "go", Namespace: DefaultNamespace} if _, f, err = client.New(context.Background(), f); err != nil { t.Fatal(err) } defer del(t, client, functionName, DefaultNamespace) // Updated function with a new set of annotations and labels // deploy and check that deployed kcsv contains correct annotations and labels annotations := map[string]string{"ann1": "val1", "ann2": "val2"} labels := []fn.Label{ {Key: ptr.String("lab1"), Value: ptr.String("v1")}, {Key: ptr.String("lab2"), Value: ptr.String("v2")}, } f.Deploy.Annotations = annotations f.Deploy.Labels = labels if f, err = client.Deploy(context.Background(), f, fn.WithDeploySkipBuildCheck(true)); err != nil { t.Fatal(err) } ksvc, err := servingClient.GetService(context.Background(), functionName) if err != nil { t.Fatal(err) } for k, v := range annotations { if val, ok := ksvc.Annotations[k]; ok { if v != val { t.Fatal(fmt.Errorf("expected annotation %q to have value %q, but the annotation in the deployed service has value %q", k, v, val)) } } else { t.Fatal(fmt.Errorf("annotation %q not found in the deployed service", k)) } } for _, l := range labels { if val, ok := ksvc.Labels[*l.Key]; ok { if *l.Value != val { t.Fatal(fmt.Errorf("expected label %q to have value %q, but the label in the deployed service has value %q", *l.Key, *l.Value, val)) } } else { t.Fatal(fmt.Errorf("label %q not found in the deployed service", *l.Key)) } } // Remove some annotations and labels // deploy and check that deployed kcsv contains correct annotations and labels annotations = map[string]string{"ann1": "val1"} labels = []fn.Label{{Key: ptr.String("lab1"), Value: ptr.String("v1")}} f.Deploy.Annotations = annotations f.Deploy.Labels = labels if f, err = client.Deploy(context.Background(), f, fn.WithDeploySkipBuildCheck(true)); err != nil { t.Fatal(err) } ksvc, err = servingClient.GetService(context.Background(), functionName) if err != nil { t.Fatal(err) } for k, v := range annotations { if val, ok := ksvc.Annotations[k]; ok { if v != val { t.Fatal(fmt.Errorf("expected annotation %q to have value %q, but the annotation in the deployed service has value %q", k, v, val)) } } else { t.Fatal(fmt.Errorf("annotation %q not found in the deployed service", k)) } } for _, l := range labels { if val, ok := ksvc.Labels[*l.Key]; ok { if *l.Value != val { t.Fatal(fmt.Errorf("expected label %q to have value %q, but the label in the deployed service has value %q", *l.Key, *l.Value, val)) } } else { t.Fatal(fmt.Errorf("label %q not found in the deployed service", *l.Key)) } } } // TestRemove ensures removal of a function instance. func TestRemove(t *testing.T) { defer Within(t, "testdata/example.com/remove")() verbose := true client := newClient(verbose) f := fn.Function{Name: "remove", Namespace: DefaultNamespace, Root: ".", Runtime: "go"} var err error if _, _, err = client.New(context.Background(), f); err != nil { t.Fatal(err) } del(t, client, "remove", DefaultNamespace) names, err := client.List(context.Background(), DefaultNamespace) if err != nil { t.Fatal(err) } if len(names) != 0 { t.Fatalf("Expected empty functions list, got %v", names) } } // TestRemoteRepositories ensures that initializing a function // defined in a remote repository finds the template, writes // the expected files, and retains the expected modes. // NOTE: this test only succeeds due to an override in // templates' copyNode which forces mode 755 for directories. // See https://github.com/go-git/go-git/issues/364 func TestRemoteRepositories(t *testing.T) { defer Within(t, "testdata/example.com/remote")() // Write the test template from the remote onto root client := fn.New( fn.WithRegistry(DefaultRegistry), fn.WithRepository("https://github.com/boson-project/test-templates"), ) _, err := client.Init(fn.Function{ Root: ".", Runtime: "runtime", Template: "template", }) if err != nil { t.Fatal(err) } tests := []struct { Path string Perm uint32 Dir bool }{ {Path: "file", Perm: 0644}, {Path: "dir-a/file", Perm: 0644}, {Path: "dir-b/file", Perm: 0644}, {Path: "dir-b/executable", Perm: 0755}, {Path: "dir-b", Perm: 0755}, {Path: "dir-a", Perm: 0755}, } // Note that .Perm() are used to only consider the least-significant 9 and // thus not have to consider the directory bit. for _, test := range tests { file, err := os.Stat(test.Path) if err != nil { t.Fatal(err) } t.Logf("%04o repository/%v", file.Mode().Perm(), test.Path) if file.Mode().Perm() != os.FileMode(test.Perm) { t.Fatalf("expected 'repository/%v' to have mode %04o, got %04o", test.Path, test.Perm, file.Mode().Perm()) } } } // TestInvoke_ClientToService ensures that the client can invoke a remotely // deployed service, both by the route returned directly as well as using // the invocation helper client.Invoke. func TestInvoke_ClientToService(t *testing.T) { var ( root, done = Mktemp(t) verbose = true ctx = context.Background() client = newClient(verbose) route string err error ) defer done() // Create a function f := fn.Function{Name: "f", Runtime: "go", Namespace: DefaultNamespace} f, err = client.Init(f) if err != nil { t.Fatal(err) } source := ` package function import ( "context" "net/http" ) func Handle(ctx context.Context, res http.ResponseWriter, req *http.Request) { res.Write([]byte("TestInvoke_ClientToService OK")) } ` err = os.WriteFile(filepath.Join(root, "handle.go"), []byte(source), os.ModePerm) if err != nil { t.Fatal(err) } if route, f, err = client.Apply(ctx, f); err != nil { t.Fatal(err) } if err := f.Write(); err != nil { t.Fatal(err) } defer del(t, client, "f", DefaultNamespace) // Invoke via the route resp, err := http.Get(route) if err != nil { t.Fatal(err) } b, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } defer resp.Body.Close() if string(b) != "TestInvoke_ClientToService OK" { t.Fatalf("unexpected response from HTTP GET: %v", b) } // Invoke using the helper _, body, err := client.Invoke(ctx, root, "", fn.NewInvokeMessage()) if err != nil { t.Fatal(err) } if body != "TestInvoke_ClientToService OK" { t.Fatalf("unexpected response from client.Invoke: %v", b) } } // TestInvoke_ServiceToService ensures that a Function can invoke another // service via localhost service discovery api provided by the Dapr sidecar. func TestInvoke_ServiceToService(t *testing.T) { var ( verbose = true ctx = context.Background() client = newClient(verbose) err error source string route string ) // Create function A // A function which responds to GET requests with a static value. root, done := Mktemp(t) defer done() f := fn.Function{Name: "a", Runtime: "go", Namespace: DefaultNamespace} f, err = client.Init(f) if err != nil { t.Fatal(err) } source = ` package function import ( "context" "net/http" ) func Handle(ctx context.Context, res http.ResponseWriter, req *http.Request) { res.Write([]byte("TestInvoke_ServiceToService OK")) } ` err = os.WriteFile(filepath.Join(root, "handle.go"), []byte(source), os.ModePerm) if err != nil { t.Fatal(err) } if _, _, err = client.Apply(ctx, f); err != nil { t.Fatal(err) } defer del(t, client, "a", DefaultNamespace) // Create Function B // which responds with the response from an invocation of 'a' via the // localhost service discovery and invocation API. root, done = Mktemp(t) defer done() f = fn.Function{Name: "b", Runtime: "go", Namespace: DefaultNamespace} f, err = client.Init(f) if err != nil { t.Fatal(err) } source = ` package function import ( "bytes" "context" "fmt" "io" "net/http" "os" "time" ) func Handle(ctx context.Context, w http.ResponseWriter, req *http.Request) { var e error var r *http.Response var buff bytes.Buffer var out io.Writer = io.MultiWriter(os.Stderr, &buff) for i := 0; i < 10; i++ { r, e = http.Get("http://localhost:3500/v1.0/invoke/a/method/") if e != nil { _, _ = fmt.Fprintf(out, "unable to invoke function a: %v\n", e) time.Sleep(time.Second*3) continue } defer r.Body.Close() if r.StatusCode != 200 { _, _ = fmt.Fprintf(out, "bad http status code when invoking a: %d\n", r.StatusCode) time.Sleep(time.Second*3) continue } w.WriteHeader(200) _, _ = io.Copy(w, r.Body) return } http.Error(w, fmt.Sprintf("unable to invoke function a:\n%s", buff.String()), http.StatusServiceUnavailable) return } ` err = os.WriteFile(filepath.Join(root, "handle.go"), []byte(source), os.ModePerm) if err != nil { t.Fatal(err) } if route, _, err = client.Apply(ctx, f); err != nil { t.Fatal(err) } defer del(t, client, "b", DefaultNamespace) resp, err := http.Get(route) if err != nil { t.Fatal(err) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } defer resp.Body.Close() if string(body) != "TestInvoke_ServiceToService OK" { t.Fatalf("Unexpected response from Function B: %v", string(body)) } } // TestDeployWithoutHome ensures that running client.New works without // home func TestDeployWithoutHome(t *testing.T) { root, cleanup := Mktemp(t) defer cleanup() t.Setenv("HOME", "") t.Setenv("XDG_CONFIG_HOME", "") verbose := false name := "test-deploy-no-home" f := fn.Function{Runtime: "node", Name: name, Root: root, Namespace: DefaultNamespace} // client with s2i builder because pack needs HOME client := newClientWithS2i(verbose) // expect to succeed _, _, err := client.New(context.Background(), f) if err != nil { t.Fatalf("expected no errors but got %v", err) } defer del(t, client, name, DefaultNamespace) } // *********** // Helpers // *********** // newClient creates an instance of the func client with concrete impls // sufficient for running integration tests. func newClient(verbose bool) *fn.Client { builder := buildpacks.NewBuilder(buildpacks.WithVerbose(verbose)) pusher := docker.NewPusher(docker.WithVerbose(verbose)) deployer := knative.NewDeployer(knative.WithDeployerVerbose(verbose)) describer := knative.NewDescriber(verbose) remover := knative.NewRemover(verbose) lister := knative.NewLister(verbose) return fn.New( fn.WithRegistry(DefaultRegistry), fn.WithVerbose(verbose), fn.WithBuilder(builder), fn.WithPusher(pusher), fn.WithDeployer(deployer), fn.WithDescriber(describer), fn.WithRemover(remover), fn.WithLister(lister), ) } // copy of newClient just builder is s2i instead of buildpacks func newClientWithS2i(verbose bool) *fn.Client { builder := s2i.NewBuilder(s2i.WithVerbose(verbose)) pusher := docker.NewPusher(docker.WithVerbose(verbose)) deployer := knative.NewDeployer(knative.WithDeployerVerbose(verbose)) describer := knative.NewDescriber(verbose) remover := knative.NewRemover(verbose) lister := knative.NewLister(verbose) return fn.New( fn.WithRegistry(DefaultRegistry), fn.WithVerbose(verbose), fn.WithBuilder(builder), fn.WithPusher(pusher), fn.WithDeployer(deployer), fn.WithDescriber(describer), fn.WithRemover(remover), fn.WithLister(lister), ) } // Del cleans up after a test by removing a function by name. // (test fails if the named function does not exist) // // Intended to be run in a defer statement immediately after creation, del // works around the asynchronicity of the underlying platform's creation // step by polling the provider until the names function becomes available // (or the test times out), before firing off a deletion request. // Of course, ideally this would be replaced by the use of a synchronous // method, or at a minimum a way to register a callback/listener for the // creation event. This is what we have for now, and the show must go on. func del(t *testing.T, c *fn.Client, name, namespace string) { t.Helper() waitFor(t, c, name, namespace) f := fn.Function{Name: name, Deploy: fn.DeploySpec{Namespace: DefaultNamespace}} if err := c.Remove(context.Background(), "", "", f, false); err != nil { t.Fatal(err) } cli, _, err := docker.NewClient(client.DefaultDockerHost) if err != nil { t.Fatal(err) } defer cli.Close() opts := volume.ListOptions{ Filters: filters.NewArgs( filters.Arg("name", fmt.Sprintf("pack-cache-func_%s_*", name)), ), } resp, err := cli.VolumeList(context.Background(), opts) if err != nil { t.Fatal(err) } for _, vol := range resp.Volumes { t.Log("deleting volume:", vol.Name) err = cli.VolumeRemove(context.Background(), vol.Name, true) if err != nil { t.Fatal(err) } } } // waitFor the named function to become available in List output. // TODO: the API should be synchronous, but that depends first on // Create returning the derived name such that we can bake polling in. // Ideally the provider's Create would be made syncrhonous. func waitFor(t *testing.T, c *fn.Client, name, namespace string) { t.Helper() var pollInterval = 2 * time.Second for { // ever (i.e. defer to global test timeout) nn, err := c.List(context.Background(), namespace) if err != nil { t.Fatal(err) } for _, n := range nn { if n.Name == name { return } } time.Sleep(pollInterval) } }