func/pkg/functions/function_test.go

550 lines
15 KiB
Go

//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")
}
}