new game, fs repo layer
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPerm = 0o644
|
||||
lockFile = ".lock"
|
||||
oldFileSuffix = ".old"
|
||||
newFileSuffix = ".new"
|
||||
)
|
||||
|
||||
type fs struct {
|
||||
root string
|
||||
lock *os.File
|
||||
}
|
||||
|
||||
func NewFileStorage(path string) (*fs, error) {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("path %s invalid: %s", path, err)
|
||||
}
|
||||
if ok, err := dirExists(absPath); err != nil {
|
||||
return nil, fmt.Errorf("check dir exist: %s", err)
|
||||
} else if !ok {
|
||||
return nil, errors.New("directory does not exist: " + absPath)
|
||||
}
|
||||
|
||||
if ok, err := writable(absPath); err != nil {
|
||||
return nil, fmt.Errorf("check dir access: %s", err)
|
||||
} else if !ok {
|
||||
return nil, errors.New("directory should have read-write access: " + absPath)
|
||||
}
|
||||
|
||||
fs := &fs{
|
||||
root: path,
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
func (f *fs) Lock() (func() error, error) {
|
||||
lockPath := f.lockFilePath()
|
||||
exists, err := fileExists(lockPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check lock file exists: %s", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, errors.New("lock file already exists")
|
||||
}
|
||||
fd, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create lock file: %s", err)
|
||||
}
|
||||
f.lock = fd
|
||||
unlock := func() error {
|
||||
if err := f.lock.Close(); err != nil {
|
||||
return fmt.Errorf("close lock file: %s", err)
|
||||
}
|
||||
if err := os.Remove(f.lock.Name()); err != nil {
|
||||
return fmt.Errorf("remove lock file: %s", err)
|
||||
}
|
||||
f.lock = nil
|
||||
return nil
|
||||
}
|
||||
if _, err := f.lock.Write(big.NewInt(time.Now().UnixMilli()).Bytes()); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("write lock file: %s", err), unlock())
|
||||
}
|
||||
return unlock, nil
|
||||
}
|
||||
|
||||
func (f *fs) Write(path string, data []byte) error {
|
||||
if f.lock == nil {
|
||||
return errors.New("lock must be acquired before write")
|
||||
}
|
||||
|
||||
targetFilePath := filepath.Join(f.root, path)
|
||||
if targetFilePath == f.lockFilePath() {
|
||||
return errors.New("can't write to the lock file")
|
||||
}
|
||||
|
||||
targetDir := filepath.Dir(targetFilePath)
|
||||
if targetDir != f.root {
|
||||
ok, err := dirExists(targetDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check target dir exists: %s", err)
|
||||
}
|
||||
if !ok {
|
||||
err := os.MkdirAll(targetDir, os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create target dirs: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
oldFilePath := targetFilePath + oldFileSuffix
|
||||
|
||||
targetExists, err := fileExists(targetFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check target file exists: %s", err)
|
||||
}
|
||||
if targetExists {
|
||||
oldFileExists, err := fileExists(oldFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check old file exists: %s", err)
|
||||
}
|
||||
if oldFileExists {
|
||||
return fmt.Errorf("old file exists at: %s", oldFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
newFilePath := targetFilePath + newFileSuffix
|
||||
newFileExists, err := fileExists(newFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check new file exists: %s", err)
|
||||
}
|
||||
if newFileExists {
|
||||
return fmt.Errorf("new file exists at: %s", oldFilePath)
|
||||
}
|
||||
|
||||
err = os.WriteFile(newFilePath, data, defaultPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write data to the new file: %s", err)
|
||||
}
|
||||
|
||||
if targetExists {
|
||||
if err := os.Rename(targetFilePath, oldFilePath); err != nil {
|
||||
return fmt.Errorf("rename target file to the old file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(newFilePath, targetFilePath); err != nil {
|
||||
return fmt.Errorf("rename new file to the target file: %s", err)
|
||||
}
|
||||
|
||||
if targetExists {
|
||||
err := os.Remove(oldFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove old file: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fs) Read(path string) ([]byte, error) {
|
||||
if f.lock != nil {
|
||||
return nil, errors.New("lock must be released before read")
|
||||
}
|
||||
|
||||
targetFilePath := filepath.Join(f.root, path)
|
||||
if targetFilePath == f.lockFilePath() {
|
||||
return nil, errors.New("can't read from the lock file")
|
||||
}
|
||||
|
||||
return os.ReadFile(targetFilePath)
|
||||
}
|
||||
|
||||
func (f *fs) lockFilePath() string {
|
||||
return filepath.Join(f.root, lockFile)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewFileStorageSuccess(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
_, err := NewFileStorage(root)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLock(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
unlock, err := fs.Lock()
|
||||
assert.NoError(t, err, "acquire lock")
|
||||
exists, err := fileExists(filepath.Join(root, lockFile))
|
||||
assert.NoError(t, err, "check that the lock file should exist")
|
||||
assert.True(t, exists, "lock file must exists")
|
||||
err = unlock()
|
||||
assert.NoError(t, err, "unlocking existing lock")
|
||||
exists, err = fileExists(filepath.Join(root, lockFile))
|
||||
assert.NoError(t, err, "check that the lock file does not exist")
|
||||
assert.False(t, exists, "lock file must be removed")
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage: %s", err)
|
||||
unlock, err := fs.Lock()
|
||||
assert.NoError(t, err, "acquire lock: %s", err)
|
||||
|
||||
dirName := "some-dir"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
err string
|
||||
}{
|
||||
{path: "file-1.ext"},
|
||||
{path: "/dir/file-2.ext"},
|
||||
{path: "dir/subdir/file-3.ext"},
|
||||
{path: lockFile, err: "write to the lock file"},
|
||||
{path: dirName, err: "wrong type"},
|
||||
{path: "/" + dirName, err: "wrong type"},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
err = fs.Write(tc.path, []byte{0, 1, 2, 3})
|
||||
if tc.err == "" {
|
||||
if err != nil {
|
||||
assert.Fail(t, "not expecting an error", "write to file %s: %s", tc.path, err)
|
||||
} else {
|
||||
exists, err := fileExists(filepath.Join(root, tc.path))
|
||||
assert.NoError(t, err, "check is written file exists")
|
||||
assert.True(t, exists, "the written file should exist")
|
||||
}
|
||||
} else if tc.err != "" {
|
||||
if err == nil {
|
||||
assert.Fail(t, "expecting an error, got none", "write to file %s", tc.path)
|
||||
} else {
|
||||
assert.True(t, strings.Contains(err.Error(), tc.err), "expect: %q got: %q", tc.err, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
err = unlock()
|
||||
assert.NoError(t, err, "unlocking existing lock")
|
||||
}
|
||||
|
||||
func TestRead(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage: %s", err)
|
||||
|
||||
dirName := "some-dir"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileName := "some-file.ext"
|
||||
if err := os.WriteFile(filepath.Join(root, fileName), []byte{1, 2, 3, 4}, os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
lock bool
|
||||
err string
|
||||
}{
|
||||
{path: fileName},
|
||||
{path: "/" + fileName},
|
||||
{path: fileName, lock: true, err: "lock must be released"},
|
||||
{path: lockFile, err: "read from the lock file"},
|
||||
{path: "dir/subdir/file-3.ext", err: "no such file"},
|
||||
{path: lockFile, err: "read from the lock file"},
|
||||
{path: dirName, err: "is a directory"},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
if tc.lock {
|
||||
unlock, err := fs.Lock()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire lock: %s", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := unlock(); err != nil {
|
||||
t.Fatalf("release lock: %s", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
_, err = fs.Read(tc.path)
|
||||
if tc.err == "" {
|
||||
if err != nil {
|
||||
assert.Fail(t, "read: not expecting an error, got: "+err.Error())
|
||||
} else {
|
||||
exists, err := fileExists(filepath.Join(root, tc.path))
|
||||
assert.NoError(t, err, "check is written file exists")
|
||||
assert.True(t, exists, "the written file should exist")
|
||||
}
|
||||
} else if tc.err != "" {
|
||||
if err == nil {
|
||||
assert.Fail(t, "read: expecting an error, got none")
|
||||
} else {
|
||||
assert.True(t, strings.Contains(err.Error(), tc.err), "expect: %q got: %q", tc.err, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorWithoutLock(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
err = fs.Write("some/path", []byte{0, 1, 2, 3})
|
||||
assert.Error(t, err, "should return error when no lock acquired")
|
||||
assert.True(t, strings.Contains(err.Error(), "lock must be acquired"), "should return missing lock error")
|
||||
}
|
||||
|
||||
func TestNewFileStorageErrorNotExists(t *testing.T) {
|
||||
_, err := NewFileStorage(filepath.Join(os.TempDir(), "non-existent-dir"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewFileStorageErrorNotADirectory(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "fs-test-file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = NewFileStorage(f.Name())
|
||||
assert.Error(t, err)
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFileStorageErrorNoAccess(t *testing.T) {
|
||||
_, err := NewFileStorage(nonWritableDir)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
nonWritableDir = "/usr/lib"
|
||||
)
|
||||
|
||||
func createWorkDir(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "fs-test-workdir")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %s", err)
|
||||
}
|
||||
return dir, func() {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatalf("remove temp dir: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build !windows
|
||||
|
||||
// for windows builds func [writable] should be refactored
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func dirExists(path string) (bool, error) {
|
||||
return pathExists(path, true)
|
||||
}
|
||||
|
||||
func fileExists(path string) (bool, error) {
|
||||
return pathExists(path, false)
|
||||
}
|
||||
|
||||
func pathExists(path string, isDir bool) (bool, error) {
|
||||
if fi, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
} else {
|
||||
if isDir != fi.IsDir() {
|
||||
return false, fmt.Errorf("wrong type: "+path+" mode=%s isDir=%t", fi.Mode(), isDir)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func writable(filepath string) (bool, error) {
|
||||
return unix.Access(filepath, unix.W_OK) == nil, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPathExists(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
testDirExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, true) })
|
||||
testFileExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, false) })
|
||||
}
|
||||
|
||||
func TestDirExists(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
testDirExistsFunc(t, root, dirExists)
|
||||
}
|
||||
|
||||
func TestFileExists(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
testFileExistsFunc(t, root, fileExists)
|
||||
}
|
||||
|
||||
func TestWritable(t *testing.T) {
|
||||
root, cleanup := createWorkDir(t)
|
||||
defer cleanup()
|
||||
ok, err := writable(root)
|
||||
assert.NoError(t, err, "directory writable check")
|
||||
assert.True(t, ok, "directory should be writable")
|
||||
|
||||
ok, err = writable(nonWritableDir)
|
||||
assert.NoError(t, err, "system directory writable check")
|
||||
assert.False(t, ok, "system directory should not be writable")
|
||||
}
|
||||
|
||||
func testDirExistsFunc(t *testing.T, root string, dirCheck func(string) (bool, error)) {
|
||||
exists, err := dirCheck(root)
|
||||
assert.NoError(t, err, "directory existence check")
|
||||
assert.True(t, exists, "directory should exist")
|
||||
nonExistentDir := filepath.Join(root, uuid.New().String())
|
||||
exists, err = dirCheck(nonExistentDir)
|
||||
assert.NoError(t, err, "non-existent directory existence check")
|
||||
assert.False(t, exists, "non-existent directory should not exist")
|
||||
}
|
||||
|
||||
func testFileExistsFunc(t *testing.T, root string, fileCheck func(string) (bool, error)) {
|
||||
fpath := createTempFile(t, root)
|
||||
exists, err := fileCheck(fpath)
|
||||
assert.NoError(t, err, "file existence check")
|
||||
assert.True(t, exists, "file should exist")
|
||||
nonExistentFile := filepath.Join(root, uuid.New().String())
|
||||
exists, err = fileCheck(nonExistentFile)
|
||||
assert.NoError(t, err, "non-existent file existence check")
|
||||
assert.False(t, exists, "non-existent file should not exist")
|
||||
}
|
||||
|
||||
func createTempFile(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
|
||||
fd, err := os.CreateTemp(root, "a-file")
|
||||
if err != nil {
|
||||
assert.FailNow(t, "create temporary file", err)
|
||||
return ""
|
||||
}
|
||||
if err := fd.Close(); err != nil {
|
||||
assert.FailNow(t, "close temporary file", err)
|
||||
return ""
|
||||
}
|
||||
return fd.Name()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package repo
|
||||
|
||||
import "github.com/iliadenisov/galaxy/pkg/repo/fs"
|
||||
|
||||
type Storage interface {
|
||||
Lock() (func() error, error)
|
||||
Write(string, []byte) error
|
||||
}
|
||||
|
||||
type repo struct {
|
||||
s Storage
|
||||
}
|
||||
|
||||
func NewRepo(s Storage) (*repo, error) {
|
||||
r := &repo{
|
||||
s: s,
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func NewFileRepo(path string) (*repo, error) {
|
||||
s, err := fs.NewFileStorage(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewRepo(s)
|
||||
}
|
||||
Reference in New Issue
Block a user