//go:build !integration // +build !integration package functions_test import ( "context" "fmt" "os" "path/filepath" "regexp" "strings" "testing" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/object" "github.com/google/go-cmp/cmp" fn "knative.dev/func/pkg/functions" "knative.dev/func/pkg/mock" . "knative.dev/func/pkg/testing" ) // TestFunction_PathDefault ensures that the default path when instantiating // a NewFunciton is to use the current working directory. func TestFunction_PathDefault(t *testing.T) { root, rm := Mktemp(t) defer rm() var f fn.Function var err error if f, err = fn.NewFunction(root); err != nil { t.Fatal(err) } f.Name = "f" f.Runtime = "go" if err := f.Write(); err != nil { t.Fatal(err) } if f, err = fn.NewFunction(""); err != nil { t.Fatal(err) } if f.Name != "f" { t.Fatalf("expected function 'f', got '%v'", f.Name) } } // TestFunction_PathErrors ensures that instantiating a function errors if // the path does not exist or is not a directory, but does not require the // path contain an initialized function. func TestFunction_PathErrors(t *testing.T) { root, rm := Mktemp(t) defer rm() _, err := fn.NewFunction(root) if err != nil { t.Fatalf("an empty but valid directory path should not error. got '%v'", err) } _, err = fn.NewFunction(filepath.Join(root, "nonexistent")) if err == nil { t.Fatalf("a nonexistent path should error") } if err := os.WriteFile("filepath", []byte{}, os.ModePerm); err != nil { t.Fatal(err) } _, err = fn.NewFunction(filepath.Join(root, "filepath")) if err == nil { t.Fatalf("an invalid path (non-directory) should error") } } // TestFunction_WriteIdempotency ensures that a function can be written repeatedly // without change. func TestFunction_WriteIdempotency(t *testing.T) { root, rm := Mktemp(t) defer rm() client := fn.New(fn.WithRegistry(TestRegistry)) // Create a function f := fn.Function{ Runtime: TestRuntime, Root: root, } _, err := client.Init(f) if err != nil { t.Fatal(err) } // Load the function and write it again f1, err := fn.NewFunction(root) if err != nil { t.Fatal(err) } if err := f1.Write(); err != nil { t.Fatal(err) } // Load it again and compare f2, err := fn.NewFunction(root) if err != nil { t.Fatal(err) } if diff := cmp.Diff(f1, f2); diff != "" { t.Error("function differs after reload (-before, +after):", diff) } } // TestFunction_NameDefault ensures that a function's name is defaulted to that // which can be derived from the last part of its path. // Creating a new function from a path will error if there is no function at // that path. Creating using the client initializes the default. func TestFunction_NameDefault(t *testing.T) { // A path at which there is no function currently root := "testdata/testFunctionNameDefault" defer Using(t, root)() f, err := fn.NewFunction(root) if err != nil { t.Fatal(err) } if f.Initialized() { t.Fatal("a function about an empty, but valid path, shold not be initialized") } // Create the function at the path client := fn.New(fn.WithRegistry(TestRegistry)) f = fn.Function{ Runtime: TestRuntime, Root: root, } f, err = client.Init(f) if err != nil { t.Fatal(err) } // Verify the name was defaulted as expected if f.Name != "testFunctionNameDefault" { t.Fatalf("expected name 'testFunctionNameDefault', got '%v'", f.Name) } } // Test_Interpolate ensures environment variable interpolation processes // environment variables by interpolating properly formatted references to // local environment variables, returning a final simple map structure. // Also ensures that nil value references are interpreted as meaning the // environment is not to be included in the resultant map, rather than included // with an empty value. // TODO: Perhaps referring to a nonexistent local env var should be treated // as a "leave as is" (do not set) rather than "required" resulting in error? // TODO: What use case does a nil pointer in the Env struct serve? Add it // explicitly here ore get rid of the nils. func Test_Interpolate(t *testing.T) { t.Setenv("INTERPOLATE", "interpolated") cases := []struct { Value string Expected string Error bool }{ // Simple values are kept unchanged {Value: "simple value", Expected: "simple value"}, // Properly referenced environment variables are interpolated {Value: "{{ env:INTERPOLATE }}", Expected: "interpolated"}, // Other interpolation types other than "env" are left unchanged {Value: "{{ other:TYPE }}", Expected: "{{ other:TYPE }}", Error: false}, // Properly formatted references to missing variables error {Value: "{{ env:MISSING }}", Expected: "", Error: true}, } name := "NAME" // default name for all tests for _, c := range cases { t.Logf("Value: %v\n", c.Value) var ( envs = []fn.Env{{Name: &name, Value: &c.Value}} // pre-interpolated vv, err = fn.Interpolate(envs) // interpolated v = vv[name] // final value ) if c.Error && err == nil { t.Fatal("expected error in Envs interpolation not received") } if v != c.Expected { t.Fatalf("expected env value '%v' to be interpolated as '%v', but got '%v'", c.Value, c.Expected, v) } } // Nil value should be treated as being disincluded from the resultant map. envs := []fn.Env{{Name: &name}} // has a nil *Value ptr vv, err := fn.Interpolate(envs) if err != nil { t.Fatal(err) } if len(vv) != 0 { t.Fatalf("expected envs with a nil value to not be included in interpolation result") } } // TestFunction_MarshallingError check that the correct error gets reported back to the // user if the function that is being loaded is failing marshalling and cannot be migrated func TestFunction_MarshallingError(t *testing.T) { root := "testdata/testFunctionMarshallingError" // Load the function to see it fail with a marshalling error _, err := fn.NewFunction(root) if err != nil { if !strings.Contains(err.Error(), "Marshalling: 'func.yaml' is not valid:") { t.Fatalf("expected unmarshalling error") } } } // TestFunction_MigrationError check that the correct error gets reported back to the // user if the function that is being loaded is failing marshalling and cannot be migrated func TestFunction_MigrationError(t *testing.T) { root := "testdata/testFunctionMigrationError" // Load the function to see it fail with a migration error _, err := fn.NewFunction(root) if err != nil { // This function makes the migration fails if !strings.Contains(err.Error(), "migration 'migrateToBuilderImages' error") { t.Fatalf("expected migration error") } } } // TestFunction_Built ensures that the function's Built method reports // filesystem changes as indicating the function is no longer Built (aka stale) // This includes modifying timestamps, removing or adding files. func TestFunction_Built(t *testing.T) { var ( ctx = context.Background() builder = mock.NewBuilder() client = fn.New(fn.WithBuilder(builder), fn.WithRegistry(TestRegistry)) testfile = "example.go" root, rm = Mktemp(t) ) defer rm() // Create and build a function, which also stamps. f, err := client.Init(fn.Function{Runtime: TestRuntime, Root: root}) if err != nil { t.Fatal(err) } if f, err = client.Build(ctx, f); err != nil { t.Fatal(err) } // Prior to a filesystem edit, it will be Built. if !f.Built() { t.Fatal("freshly built function reported Built==false (1)") } // Release thread and wait to ensure that the clock advances even in constrained CI environments time.Sleep(100 * time.Millisecond) // Edit the filesystem by touching a file (updating modified timestamp) if err := os.Chtimes(filepath.Join(root, "func.yaml"), time.Now(), time.Now()); err != nil { fmt.Println(err) } // Release thread and wait to ensure that the clock advances even in constrained CI environments time.Sleep(100 * time.Millisecond) if f.Built() { t.Fatal("client did not detect file timestamp change as indicating build staleness") } // Build and double-check Built has been reset if f, err = client.Build(ctx, f); err != nil { t.Fatal(err) } if !f.Built() { t.Fatal("freshly built function reported Built==false (2)") } // Edit the function's filesystem by adding a file. file, err := os.Create(filepath.Join(root, testfile)) if err != nil { t.Fatal(err) } file.Close() // The system should now detect the function is stale if f.Built() { t.Fatal("client did not detect an added file as indicating build staleness") } // Build and double-check Built has been reset if f, err = client.Build(ctx, f); err != nil { t.Fatal(err) } if !f.Built() { t.Fatal("freshly built function reported Built==false (3)") } // Remove the testfile, which should result in the client reporting that // the function is no longer Built (stale) if err := os.Remove(filepath.Join(root, testfile)); err != nil { t.Fatal(err) } if f.Built() { t.Fatal("client did not detect a removed file as indicating build staleness") } } // TestFunction_Stamp ensures that the Stamp method and it's associated // accessor BuildStamp: // // yields an empty string if the function is unbuilt // yields a build stamp once built // The value is unchanged on multiple invocations with an unchanged fs. // The value changes if the filesystem changes. // Creates a journal when requested. func TestFunction_Stamp(t *testing.T) { root, rm := Mktemp(t) defer rm() f := fn.Function{Root: root, Runtime: "go", Name: "f"} client := fn.New(fn.WithBuilder(mock.NewBuilder()), fn.WithRegistry(TestRegistry)) stamp := f.BuildStamp() // In-memory functions should have no buildstamp if stamp != "" { t.Fatalf("build stamp of an uninitialized function should be '', got '%v'", stamp) } // Initialized (but not built) functions should also have no stamp f, err := client.Init(f) if err != nil { t.Fatal(err) } stamp = f.BuildStamp() if stamp != "" { t.Fatalf("initial build stamp of an unbuilt but initialized function should be empty, got '%v'", stamp) } // Built functions should have a stamp f, err = client.Build(context.Background(), f) if err != nil { t.Fatal(err) } stamp = f.BuildStamp() if stamp == "" { t.Fatal("building the function did not yield a build stamp") } // Explicitly stamping again should have no effect if err = f.Stamp(); err != nil { t.Fatal(err) } stamp2 := f.BuildStamp() if stamp2 != stamp { t.Fatalf("re-stamping an unchanged function changed its stamp. expected '%v', got '%v'", stamp, stamp2) } // Windows is randomly failing the following test. This is a quick // way to confirm it's a racing condition with fs modification. // Test succeeds reliably on linux, and there is an explicit .Flush time.Sleep(1 * time.Second) // Editing the filesystem and re-stamping should have an effect if err := os.Chtimes(filepath.Join(root, "func.yaml"), time.Now(), time.Now()); err != nil { fmt.Println(err) } if err = f.Stamp(); err != nil { t.Fatal(err) } stamp2 = f.BuildStamp() if stamp2 == "" { t.Fatal("stamping a built function which has had disk changes since build resulted in an empty stamp.") } if stamp2 == stamp { t.Fatalf("stamping a changed function did not change stamp. got '%v' again", stamp2) } // Asking to stamp again with a journal should result in there being // a "[timestamp]built.log" file in .func if err = f.Stamp(fn.WithStampJournal()); err != nil { t.Fatal(err) } files, err := os.ReadDir(filepath.Join(root, fn.RunDataDir)) if err != nil { t.Fatal(err) } createdJournal := false rx := regexp.MustCompile(`^\d{4}.*built\.log$`) for _, file := range files { if rx.MatchString(file.Name()) { createdJournal = true break } } if !createdJournal { t.Fatal("expected journal log not found") } } // TestFunction_Local checks if writing a function with custom Local spec // stays the same for the current system. The test does the following: // // create a new function // set Local.Remote to true // write it to the disk // load it again into a new function object // // The load should be successful and Local.Remote should be true func TestFunction_Local(t *testing.T) { root, rm := Mktemp(t) defer rm() fConfig := fn.Function{Root: root, Runtime: "go", Name: "f"} client := fn.New(fn.WithBuilder(mock.NewBuilder()), fn.WithRegistry(TestRegistry)) f, err := client.Init(fConfig) if err != nil { t.Fatal(err) } f.Local.Remote = true err = f.Write() if err != nil { t.Fatal(err) } // Load the function from the same location f, err = fn.NewFunction(root) if err != nil { t.Fatal(err) } if !f.Local.Remote { t.Fatal("expected remote flag to be set") } } // TestFunction_LocalTransient ensures that the Local field is transient and // is not serialised in a way that affects other clones of the function. // The test does the following: // // create a function (with Local.Remote set) // push the function to a remote repo (locally setup for the test) // clone the function from the remote repo into a new location // // The new function should not have Local.Remote set (as it is a transient field) func TestFunction_LocalTransient(t *testing.T) { // Initialise a new function root, rm := Mktemp(t) defer rm() fConfig := fn.Function{Root: root, Runtime: "go", Name: "f", Image: "test:latest"} client := fn.New(fn.WithBuilder(mock.NewBuilder())) f, err := client.Init(fConfig) if err != nil { t.Fatal(err) } f.Local.Remote = true err = f.Write() if err != nil { t.Fatal(err) } // Initialise the function directory as a git repo repo, err := git.PlainInit(root, false) if err != nil { t.Fatal(err) } // commit the function files wt, err := repo.Worktree() if err != nil { t.Fatal(err) } if _, err = wt.Add("."); err != nil { t.Fatal(err) } if _, err = wt.Commit("init", &git.CommitOptions{ All: true, AllowEmptyCommits: false, Author: &object.Signature{ Name: "xyz", Email: "xyz@abc.com", When: time.Now(), }, Committer: &object.Signature{ Name: "xyz", Email: "xyz@abc.com", When: time.Now(), }, }); err != nil { t.Fatal(err) } // Create a remote and push the function remotePath, remoteRm := Mktemp(t) defer remoteRm() if _, err = git.PlainInit(remotePath, true); err != nil { t.Fatal(err) } _, err = repo.CreateRemote(&config.RemoteConfig{ Name: "origin", URLs: []string{remotePath}, Mirror: false, Fetch: nil, }) if err != nil { t.Fatal(err) } err = repo.Push(&git.PushOptions{ RemoteName: "origin", RemoteURL: remotePath, InsecureSkipTLS: true, }) if err != nil { t.Fatal() } // Create a new directory to clone the function in newRoot, newRm := Mktemp(t) defer newRm() // Clone the pushed function _, err = git.PlainClone(newRoot, false, &git.CloneOptions{ URL: remotePath, RemoteName: "origin", InsecureSkipTLS: true, }) if err != nil { t.Fatal(err) } // Read the function from the new location newFunc, err := fn.NewFunction(newRoot) if err != nil { t.Fatal(newFunc, err) } if newFunc.Local.Remote { t.Fatal("Remote not supposed to be set") } }