This commit is contained in:
Bruno Carlin 2022-05-25 18:12:03 +02:00
parent 616a5d9aba
commit 73e0b22fb4
7 changed files with 2302 additions and 0 deletions

72
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,72 @@
image: golang:latest
stages:
- test
- build
- release
variables:
GOPATH: "${CI_PROJECT_DIR}/.gocache"
GOLANGCI_LINT_CACHE: "${GOPATH}/golangci-lint_cache"
GOCACHE: "${GOPATH}/go-build"
.cache: &depscache
key: $CI_COMMIT_REF_SLUG
paths:
- $CI_PROJECT_DIR/.gocache
code_navigation:
stage: test
image: sourcegraph/lsif-go:latest
allow_failure: true # recommended
script:
- lsif-go --no-animation
- ls -lh dump.lsif
artifacts:
reports:
lsif: dump.lsif
lint:
stage: test
image: golangci/golangci-lint:latest
script:
- golangci-lint cache status
- golangci-lint run --timeout 5m --out-format junit-xml > lint.junit.xml
cache:
<<: *depscache
policy: pull-push
artifacts:
reports:
junit: lint.junit.xml
tests:
stage: test
script:
- export PATH="$PATH:$GOPATH/bin"
- go install gotest.tools/gotestsum@latest
- gotestsum --junitfile tests.junit.xml -- -coverprofile=coverage.txt -covermode atomic -race ./...
after_script:
- export PATH="$PATH:$GOPATH/bin"
- go install github.com/boumenot/gocover-cobertura@latest
- gocover-cobertura < coverage.txt > coverage.xml
- go tool cover -func=coverage.txt | grep "total:"
coverage: '/total:\s+\(statements\)\s+(\d+.\d+\%)/'
cache:
<<: *depscache
policy: pull-push
artifacts:
reports:
cobertura: coverage.xml
junit: tests.junit.xml
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
script:
- |
release-cli create \
--name "${CI_COMMIT_TAG}" \
--tag-name "${CI_COMMIT_TAG}" \
--milestone "${CI_COMMIT_TAG}"

1626
.golangci.yml Normal file

File diff suppressed because it is too large Load diff

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 go
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

156
filestore.go Normal file
View file

@ -0,0 +1,156 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
var (
errFileNotFound = errors.New("file not found")
errFilenameGeneration = errors.New("cannot generate filename")
)
// NameGenerator is the type of the functions that generate the filenames
// under which the files are stored on disk from thz name the user passed
// as argument.
type NameGenerator func(string) (string, error)
// NameIdentity returns the original filename unchanged.
func NameIdentity(originalName string) (string, error) {
return originalName, nil
}
// FilestoreOption are options passed to the Fikeqtore constructors.
type FilestoreOption func(*Filestore)
// WithNameGen allows to set a custom name generator on the filestore.
func WithNameGen(f NameGenerator) FilestoreOption {
return func(fs *Filestore) {
fs.nameGenerator = f
}
}
// Filestore is an abstraction of the filesystem that allow one to store and
// get files in a simple way.
type Filestore struct {
nameGenerator NameGenerator
buckets map[string]*Bucket
dataDir string
}
// NewFilestore creates a new filestore.
func NewFilestore(path string, opts ...FilestoreOption) (*Filestore, error) {
f := &Filestore{
dataDir: path,
nameGenerator: NameIdentity,
buckets: map[string]*Bucket{},
}
for i := range opts {
opts[i](f)
}
if err := os.MkdirAll(f.dataDir, 0o700); err != nil {
return nil, fmt.Errorf("cannot create the filestore directory: %w", err)
}
return f, nil
}
// Bucket gets a bucket from the filestore. If the asked bucket does not exist,
// it is created.
func (f *Filestore) Bucket(name string) *Bucket {
if b, ok := f.buckets[name]; ok {
return b
}
b := &Bucket{
dataDir: filepath.Join(f.dataDir, name),
nameGenerator: f.nameGenerator,
}
f.buckets[name] = b
return b
}
// Bucket is a storage unit in the filestore.
type Bucket struct {
nameGenerator func(string) (string, error)
dataDir string
}
// Put stores a new file in the bucket.
func (b Bucket) Put(originalName string, r io.Reader) (string, error) {
var (
finalName string
err error
)
finalName, err = b.nameGenerator(originalName)
if err != nil {
return "", err
}
fullpath := filepath.Clean(filepath.Join(b.dataDir, finalName))
err = os.MkdirAll(filepath.Dir(fullpath), 0o700)
if err != nil {
return "", fmt.Errorf("cannot create filestore subdirectories: %w", err)
}
fd, err := os.Create(fullpath)
if err != nil {
return "", fmt.Errorf("cannot create file %s: %w", fullpath, err)
}
defer func() { _ = fd.Close() }() //nolint:errcheck // nothing to handle the error
if _, err := io.Copy(fd, r); err != nil {
return "", fmt.Errorf("cannot write the content of file %s: %w", fullpath, err)
}
return finalName, nil
}
// Get retrieves a file from the bucket.
func (b Bucket) Get(name string) (io.ReadCloser, error) {
if !b.Exists(name) {
return nil, errFileNotFound
}
r, err := os.Open(filepath.Clean(filepath.Join(b.dataDir, name)))
if err != nil {
return nil, fmt.Errorf("cannot open file %s: %w", name, err)
}
return r, nil
}
// Exists checks the existence of a file in the bucket.
func (b Bucket) Exists(filename string) bool {
if _, err := os.Stat(filepath.Join(b.dataDir, filename)); !os.IsNotExist(err) {
return true
}
return false
}
// Delete removes a file from the bucket. It is removed from storage and cannot
// be recovered.
// An error is returned if the file could not be removed.
func (b Bucket) Delete(filename string) error {
if err := os.Remove(filepath.Join(b.dataDir, filename)); err != nil {
return fmt.Errorf("cannot remove file: %w", err)
}
return nil
}
// SetNameGenerator sets the nameGenerator for this Bucket.
func (b *Bucket) SetNameGenerator(g func(string) (string, error)) {
b.nameGenerator = g
}

403
filestore_test.go Normal file
View file

@ -0,0 +1,403 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
const filecontent = "the content of my file"
type errorReader struct{}
func (errorReader) Read(_ []byte) (int, error) {
return 0, errors.New("reading error")
}
var _ io.Reader = errorReader{}
func nameGenAddress(f NameGenerator) string {
return fmt.Sprintf("%p", f)
}
func initStore(t *testing.T, path string) *Filestore {
t.Helper()
fs, err := NewFilestore(path)
require.NoError(t, err, "cannot initialize filestore")
return fs
}
func TestFilestore(t *testing.T) {
t.Parallel()
const bucket = "foo"
t.Run("Initialize a store", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
assert.NotNil(fs)
})
t.Run("Initialize a store in a directory that can't be written", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
tmpDir = filepath.Join(tmpDir, "unwritable")
err := os.Mkdir(tmpDir, 0o400)
assert.NoError(err)
tmpDir = filepath.Join(tmpDir, "subdir")
fs, err := NewFilestore(tmpDir)
assert.Error(err)
assert.Nil(fs)
})
t.Run("Selecting a new bucket", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
b := fs.Bucket(bucket)
assert.NotNil(b)
})
t.Run("Select again a bucket", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
b := fs.Bucket(bucket)
assert.NotNil(b)
b2 := fs.Bucket(bucket)
assert.NotNil(b2)
assert.Same(b, b2)
})
t.Run("The Default name generator is identity", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
b := fs.Bucket(bucket)
assert.NotNil(b)
assert.Equal(nameGenAddress(NameIdentity), nameGenAddress(b.nameGenerator))
})
t.Run("A custom name generator can be used", func(t *testing.T) {
t.Parallel()
nameGen := func(s string) (string, error) {
return s, nil
}
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir, WithNameGen(nameGen))
assert.NoError(err)
b := fs.Bucket(bucket)
assert.NotNil(b)
assert.Equal(nameGenAddress(nameGen), nameGenAddress(b.nameGenerator))
})
}
func TestBucket(t *testing.T) {
t.Parallel()
const bucket = "foo"
t.Run("the name generator of a bucket can be overloaded", func(t *testing.T) {
t.Parallel()
nameGen := func(s string) (string, error) {
return s, nil
}
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
b := fs.Bucket(bucket)
assert.NotNil(b)
b.SetNameGenerator(nameGen)
assert.NotEqual(nameGenAddress(fs.nameGenerator), nameGenAddress(b.nameGenerator))
assert.Equal(nameGenAddress(nameGen), nameGenAddress(b.nameGenerator))
})
}
func TestBucketPut(t *testing.T) {
t.Parallel()
const (
bucket = "foo"
filename = "foo.bar"
)
t.Run("It should store a file", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
assert := require.New(t)
b := fs.Bucket(bucket)
fr := strings.NewReader(filecontent)
name, err := b.Put(filename, fr)
assert.NoError(err, "cannot store file")
assert.Equal("foo.bar", name)
assert.FileExists(filepath.Join(tmpDir, bucket, filename),
"the file does not exist on disk")
defer b.Delete(filename)
assert.True(b.Exists(name),
"the file does not exist in the store")
})
t.Run("It returns an error if the name cannot be generated", func(t *testing.T) {
t.Parallel()
errNameGen := func(_ string) (string, error) {
return "", errFilenameGeneration
}
tmpDir := t.TempDir()
assert := require.New(t)
fs, err := NewFilestore(tmpDir, WithNameGen(errNameGen))
assert.NoError(err)
fr := strings.NewReader(filecontent)
name, err := fs.Bucket(bucket).Put(filename, fr)
assert.Error(err)
assert.Equal("", name)
})
t.Run("It returns an error if the bucket dir cannot be created", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
assert := require.New(t)
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
err = os.Chmod(tmpDir, 0o400)
assert.NoError(err)
fr := strings.NewReader(filecontent)
name, err := fs.Bucket(bucket).Put(filename, fr)
assert.Error(err)
assert.Equal("", name)
})
t.Run("It returns an error if the file cannot be opened", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
bucketDir := filepath.Join(tmpDir, bucket)
err = os.Mkdir(bucketDir, 0o400)
assert.NoError(err)
fr := strings.NewReader(filecontent)
name, err := fs.Bucket(bucket).Put(filename, fr)
assert.Error(err)
assert.Equal("", name)
})
t.Run("It returns an error if the file cannot be written", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs, err := NewFilestore(tmpDir)
assert.NoError(err)
fr := errorReader{}
name, err := fs.Bucket(bucket).Put(filename, fr)
assert.Error(err)
assert.Equal("", name)
})
}
func TestBucketGet(t *testing.T) {
t.Parallel()
const (
bucket = "foo"
filename = "foo.bar"
)
t.Run("It should retrieve a file", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
b := fs.Bucket(bucket)
fullpath := filepath.Join(tmpDir, bucket, filename)
err := os.Mkdir(filepath.Dir(fullpath), 0o700)
assert.NoError(err)
err = os.WriteFile(fullpath, []byte(filecontent), 0o600)
assert.NoError(err)
defer b.Delete(filename)
r, err := b.Get(filename)
assert.NoError(err)
defer func() { _ = r.Close() }()
content, err := io.ReadAll(r)
assert.NoError(err)
assert.Equal(filecontent, string(content))
})
t.Run("It returns an error if the file is not found", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
b := fs.Bucket(bucket)
r, err := b.Get(filename)
assert.ErrorIs(err, errFileNotFound)
assert.Nil(r)
})
t.Run("It returns an error if the file cannot be read", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
b := fs.Bucket(bucket)
fullpath := filepath.Join(tmpDir, bucket, filename)
err := os.Mkdir(filepath.Dir(fullpath), 0o700)
assert.NoError(err)
//revive:disable-next-line:add-constant // Only used here for tests
err = os.WriteFile(fullpath, []byte(filecontent), 0o100)
assert.NoError(err)
defer b.Delete(filename)
r, err := b.Get(filename)
assert.Error(err)
assert.Nil(r)
})
}
func TestBucketExists(t *testing.T) {
t.Parallel()
const (
bucket = "foo"
filename = "foo.bar"
)
t.Run("It returns true if the file exists", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
b := fs.Bucket(bucket)
fullpath := filepath.Join(tmpDir, bucket, filename)
err := os.Mkdir(filepath.Dir(fullpath), 0o700)
assert.NoError(err)
err = os.WriteFile(fullpath, []byte(filecontent), 0o600)
assert.NoError(err)
defer b.Delete(filename)
assert.True(b.Exists(filename))
})
t.Run("It returns false if the file does not exist", func(t *testing.T) {
t.Parallel()
assert := require.New(t)
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
b := fs.Bucket(bucket)
assert.False(b.Exists(filename))
})
}
func TestBucketDelete(t *testing.T) {
t.Parallel()
const (
bucket = "foo"
filename = "foo.bar"
)
t.Run("Removing a file (happy path)", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fs := initStore(t, tmpDir)
assert := require.New(t)
fr := strings.NewReader(filecontent)
name, err := fs.Bucket(bucket).Put(filename, fr)
assert.NoError(err)
defer fs.Bucket(bucket).Delete(filename)
err = fs.Bucket(bucket).Delete(name)
assert.NoError(err)
assert.NoFileExists(filepath.Join(tmpDir, bucket, filename))
assert.False(fs.Bucket("foo").Exists(name))
})
}

11
go.mod Normal file
View file

@ -0,0 +1,11 @@
module code.bcarlin.xyz/go/filestore
go 1.18
require github.com/stretchr/testify v1.7.1
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 // indirect
)

13
go.sum Normal file
View file

@ -0,0 +1,13 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 h1:dbuHpmKjkDzSOMKAWl10QNlgaZUd3V1q99xc81tt2Kc=
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=