Skip to content

Commit

Permalink
feat(action): add functions to detect changes in files
Browse files Browse the repository at this point in the history
- add new filesystem functions which can detect changes in files base on
  time-stamps
- since we work with large projects, such as Linux Kernel, this
  detection should be as fast as possible
- to make it fast, we solely rely on time, no hashing
- the idea is that firmware-action will create a time-stamp file at the
  end of each execution (for each target)
- at the start of each execution firmware-action will load time-stamp
  file from previous execution and check if any file in sources is newer
  that the saved time-stamp
- the detection function is lazy, and will return on first positive
  match
- if the no file in sources is newer, target should be considered
  up-to-date and skipped

Signed-off-by: AtomicFS <vojtech.vesely@9elements.com>
  • Loading branch information
AtomicFS committed Dec 11, 2024
1 parent 81e0b94 commit c88853b
Show file tree
Hide file tree
Showing 2 changed files with 213 additions and 0 deletions.
123 changes: 123 additions & 0 deletions action/filesystem/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"

"github.com/plus3it/gorecurcopy"
)
Expand All @@ -20,6 +22,8 @@ var (
ErrPathIsDirectory = fmt.Errorf("provided path is directory: %w", os.ErrExist)
// ErrFileNotRegular is returned when path exists, but is not a regular file
ErrFileNotRegular = errors.New("file is not regular file")
// ErrFileModified is returned when a file in given path was modified
ErrFileModified = errors.New("file has been modified since")
)

// CheckFileExists checks if file exists at PATH
Expand Down Expand Up @@ -145,3 +149,122 @@ func DirTree(root string) ([]string, error) {

return files, err
}

// LoadLastRunTime loads time of the last execution from file
func LoadLastRunTime(pathLastRun string) (time.Time, error) {
// Return zero time if file doesn't exist
err := CheckFileExists(pathLastRun)
if errors.Is(err, os.ErrNotExist) {
return time.Time{}, nil
}

content, err := os.ReadFile(pathLastRun)
// Return zero and error on reading error
if err != nil {
slog.Warn(
fmt.Sprintf("Error when reading file '%s'", pathLastRun),
slog.Any("error", err),
)
return time.Time{}, err
}

// File should contain time in RFC3339Nano format
lastRun, err := time.Parse(time.RFC3339Nano, string(content))
// Return zero and error on parsing error
if err != nil {
slog.Warn(
fmt.Sprintf("Error when parsing time-stamp from '%s'", pathLastRun),
slog.Any("error", err),
)
return time.Time{}, err
}
return lastRun, nil
}

// SaveCurrentRunTime writes the current time into file
func SaveCurrentRunTime(pathLastRun string) error {
// Create temporaryFilesDir

// Create directory if needed
dir := filepath.Dir(pathLastRun)
err := CheckFileExists(dir)
if errors.Is(err, os.ErrNotExist) {
err = os.MkdirAll(dir, os.ModePerm)
if err != nil {
return err
}
}

// Write the current time into file
return os.WriteFile(pathLastRun, []byte(time.Now().Format(time.RFC3339Nano)), 0o666)
}

// GetFileModTime returns modification time of a file
func GetFileModTime(filePath string) (time.Time, error) {
info, err := os.Stat(filePath)
if err != nil {
return time.Time{}, err
}
return info.ModTime(), nil
}

// AnyFileNewerThan checks recursively if any file in given path (can be directory or file) has
// modification time newer than the given time.
// Returns:
// - true if a newer file is found
// - false if no newer file is found or givenTime is zero
// Function is lazy, and returns on first positive occurrence.
func AnyFileNewerThan(path string, givenTime time.Time) (bool, error) {
// If path does not exist
err := CheckFileExists(path)
if errors.Is(err, os.ErrNotExist) {
return false, err
}

// If given time is zero, assume up-to-date
// This is handy especially for CI, where we can't assume that people will cache firmware-action
// timestamp directory, but they will likely cache the produced files
if givenTime.Equal(time.Time{}) {
return false, nil
}

// If path is directory
if errors.Is(err, ErrPathIsDirectory) {
errMod := filepath.WalkDir(path, func(path string, info os.DirEntry, _ error) error {
// skip .git
if info.Name() == ".git" {
return filepath.SkipDir
}
if !info.IsDir() {
fileInfo, err := info.Info()
if err != nil {
return err
}
if fileInfo.ModTime().After(givenTime) {
return fmt.Errorf("file '%s' has been modified: %w", path, ErrFileModified)
}
}
return nil
})
if errors.Is(errMod, ErrFileModified) {
return true, nil
}
return false, nil
}

// If path is file
if errors.Is(err, os.ErrExist) {
modTime, errMod := GetFileModTime(path)
if errMod != nil {
slog.Warn(
fmt.Sprintf("Encountered error when getting modification time of file '%s'", path),
slog.Any("error", errMod),
)
return false, errMod
}
return modTime.After(givenTime), nil
}

// If path is neither file nor directory
return false, err
}
90 changes: 90 additions & 0 deletions action/filesystem/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -99,3 +100,92 @@ func TestDirTree(t *testing.T) {
assert.NoError(t, err)
assert.True(t, len(files) > 0, "found no files or directories")
}

func TestLastSaveRunTime(t *testing.T) {
currentTime := time.Now()

tmpDir := t.TempDir()
pathTimeFile := filepath.Join(tmpDir, "last_run_time.txt")

// Load - should fallback because no file exists, but no error
loadTime, err := LoadLastRunTime(pathTimeFile)
assert.NoError(t, err)
assert.Equal(t, loadTime, time.Time{})
assert.ErrorIs(t, CheckFileExists(pathTimeFile), os.ErrNotExist)

// Save
err = SaveCurrentRunTime(pathTimeFile)
assert.NoError(t, err)
// file should now exist
assert.ErrorIs(t, CheckFileExists(pathTimeFile), os.ErrExist)

// Load again - should now work since file exists
loadTime, err = LoadLastRunTime(pathTimeFile)
assert.NoError(t, err)
assert.True(t, loadTime.After(currentTime))
assert.True(t, time.Now().After(loadTime))
}

func TestGetFileModTime(t *testing.T) {
tmpDir := t.TempDir()
pathFile := filepath.Join(tmpDir, "test.txt")

// Missing file - should fail
modTime, err := GetFileModTime(pathFile)
assert.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, modTime, time.Time{})
assert.ErrorIs(t, CheckFileExists(pathFile), os.ErrNotExist)

// Make file
err = os.WriteFile(pathFile, []byte{}, 0o666)
assert.NoError(t, err)
assert.ErrorIs(t, CheckFileExists(pathFile), os.ErrExist)

// Should work
_, err = GetFileModTime(pathFile)
assert.NoError(t, err)
}

func TestAnyFileNewerThan(t *testing.T) {
tmpDir := t.TempDir()
pathFile := filepath.Join(tmpDir, "test.txt")

// Call on missing file - should fail
mod, err := AnyFileNewerThan(pathFile, time.Now())
assert.ErrorIs(t, err, os.ErrNotExist)
assert.False(t, mod)

// Call on existing file
// - Make file
err = os.WriteFile(pathFile, []byte{}, 0o666)
assert.NoError(t, err)
assert.ErrorIs(t, CheckFileExists(pathFile), os.ErrExist)
// - Should work - is file newer than last year? (true)
mod, err = AnyFileNewerThan(pathFile, time.Now().AddDate(-1, 0, 0))
assert.NoError(t, err)
assert.True(t, mod)
// - Should work - is file newer than next year? (false)
mod, err = AnyFileNewerThan(pathFile, time.Now().AddDate(1, 0, 0))
assert.NoError(t, err)
assert.False(t, mod)

// Call on nested directory
// - Make directory tree
subDirRoot := filepath.Join(tmpDir, "test")
subSubDir := filepath.Join(subDirRoot, "deep_test/even_deeper")
err = os.MkdirAll(subSubDir, os.ModePerm)
assert.NoError(t, err)
// - Make file
deepFile := filepath.Join(subSubDir, "test.txt")
err = os.WriteFile(deepFile, []byte{}, 0o666)
assert.NoError(t, err)
assert.ErrorIs(t, CheckFileExists(deepFile), os.ErrExist)
// - Should work - older
mod, err = AnyFileNewerThan(subDirRoot, time.Now().AddDate(-1, 0, 0))
assert.NoError(t, err)
assert.True(t, mod)
// - Should work - newer
mod, err = AnyFileNewerThan(subDirRoot, time.Now().AddDate(1, 0, 0))
assert.NoError(t, err)
assert.False(t, mod)
}

0 comments on commit c88853b

Please sign in to comment.