Skip to content

Commit

Permalink
refactor and test
Browse files Browse the repository at this point in the history
  • Loading branch information
lkingland committed May 11, 2023
1 parent cdcd556 commit 9e1f52d
Show file tree
Hide file tree
Showing 3 changed files with 310 additions and 179 deletions.
180 changes: 179 additions & 1 deletion pkg/functions/client.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package functions

import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"

"gopkg.in/yaml.v2"
"knative.dev/func/pkg/utils"
)

Expand Down Expand Up @@ -551,7 +557,7 @@ func (c *Client) Init(cfg Function) (Function, error) {
f := NewFunctionWith(cfg)

// Create a .func diretory which is also added to a .gitignore
if err = f.ensureRuntimeDir(); err != nil {
if err = ensureRunDataDir(f.Root); err != nil {
return f, err
}

Expand Down Expand Up @@ -960,6 +966,178 @@ func (c *Client) Push(ctx context.Context, f Function) (Function, error) {
return f, nil
}

// ensureRunDataDir creates a .func directory at the given path, and
// registers it as ignored in a .gitignore file.
func ensureRunDataDir(root string) error {
// Ensure the runtime directory exists
if err := os.MkdirAll(filepath.Join(root, RunDataDir), os.ModePerm); err != nil {
return err
}

// Update .gitignore
//
// Ensure .func is added to .gitignore unless the user explicitly
// commented out the ignore line for some awful reason.
// Also creates the .gitignore in the function's root directory if it does
// not already exist (note that this may not be in the root of the repo
// if the function is at a subpath of a monorepo)
filePath := filepath.Join(root, ".gitignore")
roFile, err := os.Open(filePath)
if err != nil && !os.IsNotExist(err) {
return err
}
defer roFile.Close()
if !os.IsNotExist(err) { // if no error openeing it
s := bufio.NewScanner(roFile) // create a scanner
for s.Scan() { // scan each line
if strings.HasPrefix(s.Text(), "# /"+RunDataDir) { // if it was commented
return nil // user wants it
}
if strings.HasPrefix(s.Text(), "#/"+RunDataDir) {
return nil // user wants it
}
if strings.HasPrefix(s.Text(), "/"+RunDataDir) { // if it is there
return nil // we're done
}
}
}
// Either .gitignore does not exist or it does not have the ignore
// directive for .func yet.
roFile.Close()
rwFile, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
defer rwFile.Close()
if _, err = rwFile.WriteString(`
# Functions use the .func directory for local runtime data which should
# generally not be tracked in source control. To instruct the system to track
# .func in source control, comment the following line (prefix it with '# ').
/.func
`); err != nil {
return err
}

// Flush to disk immediately since this may affect subsequent calculations
// of the build stamp
if err = rwFile.Sync(); err != nil {
fmt.Fprintf(os.Stderr, "warning: error when syncing .gitignore. %s", err)
}
return nil
}

// fingerprint the files at a given path. Returns a hash calculated from the
// filenames and modification timestamps of the files within the given root.
// Also returns a logfile consiting of the filenames and modification times
// which contributed to the hash.
// Intended to determine if there were appreciable changes to a function's
// source code, certain directories and files are ignored, such as
// .git and .func.
// Future updates will include files explicitly marked as ignored by a
// .funcignore.
func fingerprint(root string) (hash, log string, err error) {
h := sha256.New() // Hash builder
l := bytes.Buffer{} // Log buffer

err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if path == root {
return nil
}
// Always ignore .func, .git (TODO: .funcignore)
if info.IsDir() && (info.Name() == RunDataDir || info.Name() == ".git") {
return filepath.SkipDir
}
fmt.Fprintf(h, "%v:%v:", path, info.ModTime().UnixNano()) // Write to the Hasher
fmt.Fprintf(&l, "%v:%v\n", path, info.ModTime().UnixNano()) // Write to the Log
return nil
})
return fmt.Sprintf("%x", h.Sum(nil)), l.String(), err
}

// assertEmptyRoot ensures that the directory is empty enough to be used for
// initializing a new function.
func assertEmptyRoot(path string) (err error) {
// If there exists contentious files (congig files for instance), this function may have already been initialized.
files, err := contentiousFilesIn(path)
if err != nil {
return
} else if len(files) > 0 {
return fmt.Errorf("the chosen directory '%v' contains contentious files: %v. Has the Service function already been created? Try either using a different directory, deleting the function if it exists, or manually removing the files", path, files)
}

// Ensure there are no non-hidden files, and again none of the aforementioned contentious files.
empty, err := isEffectivelyEmpty(path)
if err != nil {
return
} else if !empty {
err = errors.New("the directory must be empty of visible files and recognized config files before it can be initialized")
return
}
return
}

// contentiousFiles are files which, if extant, preclude the creation of a
// function rooted in the given directory.
var contentiousFiles = []string{
FunctionFile,
}

// contentiousFilesIn the given directory
func contentiousFilesIn(path string) (contentious []string, err error) {
files, err := os.ReadDir(path)
for _, file := range files {
for _, name := range contentiousFiles {
if file.Name() == name {
contentious = append(contentious, name)
}
}
}
return
}

// effectivelyEmpty directories are those which have no visible files
func isEffectivelyEmpty(path string) (bool, error) {
// Check for any non-hidden files
files, err := os.ReadDir(path)
if err != nil {
return false, err
}
for _, file := range files {
if !strings.HasPrefix(file.Name(), ".") {
return false, nil
}
}
return true, nil
}

// returns true if the given path contains an initialized function.
func hasInitializedFunction(path string) (bool, error) {
var err error
var filename = filepath.Join(path, FunctionFile)

if _, err = os.Stat(filename); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err // invalid path or access error
}
bb, err := os.ReadFile(filename)
if err != nil {
return false, err
}
f := Function{}
if err = yaml.Unmarshal(bb, &f); err != nil {
return false, err
}
if f, err = f.Migrate(); err != nil {
return false, err
}
return f.Initialized(), nil
}

// DEFAULTS
// ---------

Expand Down
130 changes: 130 additions & 0 deletions pkg/functions/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -51,6 +52,135 @@ func TestClient_New(t *testing.T) {
}
}

// TestClient_New_RunData ensures that the .func runtime directory is
// correctly created.
func TestClient_New_RunDataDir(t *testing.T) {
root, rm := Mktemp(t)
defer rm()
ctx := context.Background()

// Ensure the run data directory is created when the function is created
if _, _, err := fn.New().New(ctx, fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, fn.RunDataDir)); os.IsNotExist(err) {
t.Fatal("runtime directory not created when funciton created.")
}

// Ensure it is set as ignored in a .gitignore
file, err := os.Open(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
foundEntry := false
s := bufio.NewScanner(file)
for s.Scan() {
if strings.HasPrefix(s.Text(), "/"+fn.RunDataDir) {
foundEntry = true
break
}
}
if !foundEntry {
t.Fatal("run data dir not added to .gitignore")
}

// Ensure that if .gitignore already existed, it is modified not overwritten
root, rm = Mktemp(t)
defer rm()
if err = os.WriteFile(filepath.Join(root, ".gitignore"), []byte("user-directive\n"), os.ModePerm); err != nil {
t.Fatal(err)
}
if _, _, err := fn.New().New(ctx, fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}); err != nil {
t.Fatal(err)
}
containsUserDirective, containsFuncDirective := false, false
if file, err = os.Open(filepath.Join(root, ".gitignore")); err != nil {
t.Fatal(err)
}
s = bufio.NewScanner(file)
for s.Scan() { // scan each line
if strings.HasPrefix(s.Text(), "user-directive") {
containsUserDirective = true
}
if strings.HasPrefix(s.Text(), "/"+fn.RunDataDir) {
containsFuncDirective = true
}
}
if !containsUserDirective {
t.Fatal("extant .gitignore did not retain user direcives after creation")
}
if !containsFuncDirective {
t.Fatal("extant .gitignore was not modified with func data ignore directive")
}

// Ensure that the user can cancel this behavior entirely by including the
// ignore directive, but commented out.
root, rm = Mktemp(t)
defer rm()

userDirective := fmt.Sprintf("# /%v", fn.RunDataDir) // User explicity commented
funcDirective := fmt.Sprintf("/%v", fn.RunDataDir)
if err = os.WriteFile(filepath.Join(root, ".gitignore"), []byte(userDirective+"/n"), os.ModePerm); err != nil {
t.Fatal(err)
}
if _, _, err := fn.New().New(ctx, fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}); err != nil {
t.Fatal(err)
}
containsUserDirective, containsFuncDirective = false, false
if file, err = os.Open(filepath.Join(root, ".gitignore")); err != nil {
t.Fatal(err)
}
s = bufio.NewScanner(file)
for s.Scan() { // scan each line
if strings.HasPrefix(s.Text(), userDirective) {
containsUserDirective = true
}
if strings.HasPrefix(s.Text(), funcDirective) {
containsFuncDirective = true
}
}
if !containsUserDirective {
t.Fatal("The user's directive to disable modifing .gitignore was removed")
}
if containsFuncDirective {
t.Fatal("The user's directive to explicitly allow .func in source control was not respected")
}

// Ensure that in addition the the correctly formatted comment "# /.func",
// it will work if the user omits the space: "#/.func"
root, rm = Mktemp(t)
defer rm()
userDirective = fmt.Sprintf("#/%v", fn.RunDataDir) // User explicity commented but without space
if err = os.WriteFile(filepath.Join(root, ".gitignore"), []byte(userDirective+"/n"), os.ModePerm); err != nil {
t.Fatal(err)
}
if _, _, err := fn.New().New(ctx, fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}); err != nil {
t.Fatal(err)
}
containsFuncDirective = false
if file, err = os.Open(filepath.Join(root, ".gitignore")); err != nil {
t.Fatal(err)
}
s = bufio.NewScanner(file)
for s.Scan() { // scan each line
if strings.HasPrefix(s.Text(), funcDirective) {
containsFuncDirective = true
break
}
}
if containsFuncDirective {
t.Fatal("The user's directive to explicitly allow .func in source control was not respected")
}

// TODO: It is possible that we need to consider more complex situations,
// such as ensuring that files and directories with just the prefix are not
// matched, that the user can use non-absolute ignores (no slash prefix), etc.
// If this turns out to be necessary, we will need to add the test cases
// and have the implementation actually parse the file rather that simple
// line prefix checks.
}

// TestClient_New_RuntimeRequired ensures that the the runtime is an expected value.
func TestClient_New_RuntimeRequired(t *testing.T) {
// Create a root for the new function
Expand Down
Loading

0 comments on commit 9e1f52d

Please sign in to comment.