Initial commit
This commit is contained in:
136
fstore/browser.go
Normal file
136
fstore/browser.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"git.crumpington.com/public/jldb/fstore/pages"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
type browser struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func (s *Store) ServeBrowser(listenAddr string) error {
|
||||
b := &browser{s}
|
||||
http.HandleFunc("/", b.handle)
|
||||
http.Handle("/static/", http.FileServer(http.FS(staticFS)))
|
||||
return http.ListenAndServe(listenAddr, nil)
|
||||
}
|
||||
|
||||
func (b *browser) handle(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
b.handleGet(w, r)
|
||||
case http.MethodPost:
|
||||
b.handlePOST(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *browser) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
path := cleanPath(r.URL.Path)
|
||||
if err := validatePath(path); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := b.store.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
pages.Page{Path: path}.Render(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
b.store.Serve(w, r, path)
|
||||
return
|
||||
}
|
||||
|
||||
dirs, files, err := b.store.List(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pages.Page{
|
||||
Path: path,
|
||||
Dirs: dirs,
|
||||
Files: files,
|
||||
}.Render(w)
|
||||
}
|
||||
|
||||
// Handle actions:
|
||||
// - upload (multipart),
|
||||
// - delete
|
||||
func (b *browser) handlePOST(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1024 * 1024); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Form.Get("Action") {
|
||||
case "Upload":
|
||||
b.handlePOSTUpload(w, r)
|
||||
case "Delete":
|
||||
b.handlePOSTDelete(w, r)
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *browser) handlePOSTUpload(w http.ResponseWriter, r *http.Request) {
|
||||
file, handler, err := r.FormFile("File")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
relativePath := handler.Filename
|
||||
if p := r.Form.Get("Path"); p != "" {
|
||||
relativePath = p
|
||||
}
|
||||
fullPath := filepath.Join(r.URL.Path, relativePath)
|
||||
|
||||
tmpPath := b.store.GetTempFilePath()
|
||||
defer os.RemoveAll(tmpPath)
|
||||
|
||||
tmpFile, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := b.store.Store(tmpPath, fullPath); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, filepath.Dir(fullPath), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (b *browser) handlePOSTDelete(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.Form.Get("Path")
|
||||
if err := b.store.Remove(path); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, filepath.Dir(path), http.StatusSeeOther)
|
||||
}
|
||||
64
fstore/command.go
Normal file
64
fstore/command.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"git.crumpington.com/public/jldb/lib/errs"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
Path string
|
||||
Store bool
|
||||
TempID uint64
|
||||
FileSize int64 // In bytes.
|
||||
File io.Reader
|
||||
}
|
||||
|
||||
func (c command) Reader(buf *bytes.Buffer) (int64, io.Reader) {
|
||||
buf.Reset()
|
||||
vars := []any{
|
||||
uint32(len(c.Path)),
|
||||
c.Store,
|
||||
c.TempID,
|
||||
c.FileSize,
|
||||
}
|
||||
|
||||
for _, v := range vars {
|
||||
binary.Write(buf, binary.LittleEndian, v)
|
||||
}
|
||||
buf.Write([]byte(c.Path))
|
||||
|
||||
if c.Store {
|
||||
return int64(buf.Len()) + c.FileSize, io.MultiReader(buf, c.File)
|
||||
} else {
|
||||
return int64(buf.Len()), buf
|
||||
}
|
||||
}
|
||||
|
||||
func (c *command) ReadFrom(r io.Reader) error {
|
||||
pathLen := uint32(0)
|
||||
|
||||
ptrs := []any{
|
||||
&pathLen,
|
||||
&c.Store,
|
||||
&c.TempID,
|
||||
&c.FileSize,
|
||||
}
|
||||
|
||||
for _, ptr := range ptrs {
|
||||
if err := binary.Read(r, binary.LittleEndian, ptr); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
pathBuf := make([]byte, pathLen)
|
||||
if _, err := r.Read(pathBuf); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
c.Path = string(pathBuf)
|
||||
c.File = r
|
||||
|
||||
return nil
|
||||
}
|
||||
95
fstore/pages/page.go
Normal file
95
fstore/pages/page.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Page struct {
|
||||
Path string
|
||||
Dirs []string
|
||||
Files []string
|
||||
|
||||
// Created in Render.
|
||||
BreadCrumbs []Directory
|
||||
}
|
||||
|
||||
func (ctx Page) FullPath(dir string) string {
|
||||
return filepath.Join(ctx.Path, dir)
|
||||
}
|
||||
|
||||
func (ctx Page) Render(w io.Writer) {
|
||||
crumbs := []Directory{}
|
||||
current := Directory(ctx.Path)
|
||||
|
||||
for current != "/" {
|
||||
crumbs = append([]Directory{current}, crumbs...)
|
||||
current = Directory(filepath.Dir(string(current)))
|
||||
}
|
||||
|
||||
ctx.BreadCrumbs = crumbs
|
||||
authRegisterTmpl.Execute(w, ctx)
|
||||
}
|
||||
|
||||
var authRegisterTmpl = template.Must(template.New("").Parse(`
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/css/pure-min.css">
|
||||
<style>
|
||||
body {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
padding: 0.4em .6em;
|
||||
display: inline-block;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: inset 0 1px 3px #ddd;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
box-sizing: border-box;
|
||||
width: 350px;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>
|
||||
<a href="/">root</a> / {{range .BreadCrumbs}}<a href="{{.}}">{{.Name}}</a> / {{end -}}
|
||||
</h1>
|
||||
|
||||
<form class="pure-form" method="post" enctype="multipart/form-data">
|
||||
<fieldset>
|
||||
<legend>Upload a file</legend>
|
||||
<input type="hidden" name="Action" value="Upload">
|
||||
<input id="File" name="File" type="file">
|
||||
<input type="text" id="Path" name="Path" placeholder="Relative path (optional)" />
|
||||
<button type="submit" class="pure-button pure-button-primary">Upload</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<a href="../">../</a><br>
|
||||
{{range .Dirs}}
|
||||
<a href="{{$.FullPath .}}">{{.}}/</a><br>
|
||||
{{end}}
|
||||
|
||||
{{range .Files}}
|
||||
<form style="display:inline-block; margin:0;" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="Action" value="Delete">
|
||||
<input type="hidden" id="Path" name="Path" value="{{$.FullPath .}}">
|
||||
[<a href="#" onclick="this.closest('form').submit();return false;">X</a>]
|
||||
</form>
|
||||
<a href="{{$.FullPath .}}">{{.}}</a>
|
||||
<br>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
type Directory string
|
||||
|
||||
func (d Directory) Name() string {
|
||||
return filepath.Base(string(d))
|
||||
}
|
||||
63
fstore/paths.go
Normal file
63
fstore/paths.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"git.crumpington.com/public/jldb/lib/errs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func filesRootPath(rootDir string) string {
|
||||
return filepath.Clean(filepath.Join(rootDir, "files"))
|
||||
}
|
||||
|
||||
func repDirPath(rootDir string) string {
|
||||
return filepath.Clean(filepath.Join(rootDir, "rep"))
|
||||
}
|
||||
|
||||
func tempDirPath(rootDir string) string {
|
||||
return filepath.Clean(filepath.Join(rootDir, "tmp"))
|
||||
}
|
||||
|
||||
func tempFilePath(inDir string, id uint64) string {
|
||||
return filepath.Join(inDir, strconv.FormatUint(id, 10))
|
||||
}
|
||||
|
||||
func validatePath(p string) error {
|
||||
if len(p) == 0 {
|
||||
return errs.InvalidPath.WithMsg("empty path")
|
||||
}
|
||||
|
||||
if p[0] != '/' {
|
||||
return errs.InvalidPath.WithMsg("path must be absolute")
|
||||
}
|
||||
|
||||
for _, c := range p {
|
||||
switch c {
|
||||
case '/', '-', '_', '.':
|
||||
continue
|
||||
default:
|
||||
}
|
||||
|
||||
if c >= 'a' && c <= 'z' {
|
||||
continue
|
||||
}
|
||||
|
||||
if c >= '0' && c <= '9' {
|
||||
continue
|
||||
}
|
||||
|
||||
return errs.InvalidPath.WithMsg("invalid character in path: %s", string([]rune{c}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanPath(p string) string {
|
||||
if len(p) == 0 {
|
||||
return "/"
|
||||
}
|
||||
|
||||
if p[0] != '/' {
|
||||
p = "/" + p
|
||||
}
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
55
fstore/paths_test.go
Normal file
55
fstore/paths_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package fstore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePath_valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"/",
|
||||
"/a/z/0.9/a-b_c.d/",
|
||||
"/a/../b",
|
||||
"/x/abcdefghijklmnopqrstuvwxyz/0123456789/_.-",
|
||||
}
|
||||
|
||||
for _, s := range cases {
|
||||
if err := validatePath(s); err != nil {
|
||||
t.Fatal(s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePath_invalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"/A",
|
||||
"/a/b/~xyz/",
|
||||
"/a\\b",
|
||||
"a/b/c",
|
||||
}
|
||||
|
||||
for _, s := range cases {
|
||||
if err := validatePath(s); err == nil {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPath(t *testing.T) {
|
||||
type Case struct {
|
||||
In, Out string
|
||||
}
|
||||
|
||||
cases := []Case{
|
||||
{"", "/"},
|
||||
{"../", "/"},
|
||||
{"a/b", "/a/b"},
|
||||
{"/a/b/../../../", "/"},
|
||||
{"a/b/../../../", "/"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
out := cleanPath(c.In)
|
||||
if out != c.Out {
|
||||
t.Fatal(c.In, out, c.Out)
|
||||
}
|
||||
}
|
||||
}
|
||||
50
fstore/stateutil_test.go
Normal file
50
fstore/stateutil_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StoreState struct {
|
||||
Path string
|
||||
IsDir bool
|
||||
Dirs map[string]StoreState
|
||||
Files map[string]StoreState
|
||||
FileData string
|
||||
}
|
||||
|
||||
func NewStoreState(in map[string]string) StoreState {
|
||||
root := StoreState{
|
||||
Path: "/",
|
||||
IsDir: true,
|
||||
Dirs: map[string]StoreState{},
|
||||
Files: map[string]StoreState{},
|
||||
}
|
||||
|
||||
for path, fileData := range in {
|
||||
slugs := strings.Split(path[1:], "/") // Remove leading slash.
|
||||
|
||||
parent := root
|
||||
|
||||
// Add directories.
|
||||
for _, part := range slugs[:len(slugs)-1] {
|
||||
if _, ok := parent.Dirs[part]; !ok {
|
||||
parent.Dirs[part] = StoreState{
|
||||
Path: filepath.Join(parent.Path, part),
|
||||
IsDir: true,
|
||||
Dirs: map[string]StoreState{},
|
||||
Files: map[string]StoreState{},
|
||||
}
|
||||
}
|
||||
parent = parent.Dirs[part]
|
||||
}
|
||||
|
||||
parent.Files[slugs[len(slugs)-1]] = StoreState{
|
||||
Path: path,
|
||||
IsDir: false,
|
||||
FileData: fileData,
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
11
fstore/static/css/pure-min.css
vendored
Normal file
11
fstore/static/css/pure-min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
82
fstore/store-commands.go
Normal file
82
fstore/store-commands.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"git.crumpington.com/public/jldb/lib/errs"
|
||||
"git.crumpington.com/public/jldb/lib/idgen"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (s *Store) applyStoreFromReader(cmd command) error {
|
||||
tmpPath := tempFilePath(s.tmpDir, idgen.Next())
|
||||
|
||||
f, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := f.ReadFrom(cmd.File)
|
||||
if err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
if n != cmd.FileSize {
|
||||
return errs.IO.WithMsg("expected to %d bytes, but got %d", cmd.FileSize, n)
|
||||
}
|
||||
|
||||
if err := f.Sync(); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(s.filesRoot, cmd.Path)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0700); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, fullPath); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyStoreFromTempID(cmd command) error {
|
||||
tmpPath := tempFilePath(s.tmpDir, cmd.TempID)
|
||||
fullPath := filepath.Join(s.filesRoot, cmd.Path)
|
||||
|
||||
info, err := os.Stat(tmpPath)
|
||||
if err != nil || info.Size() != cmd.FileSize {
|
||||
log.Printf("[STORE] Primary falling back on reader copy: %v", err)
|
||||
return s.applyStoreFromReader(cmd)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0700); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, fullPath); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyRemove(cmd command) error {
|
||||
finalPath := filepath.Join(s.filesRoot, cmd.Path)
|
||||
if err := os.Remove(finalPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
parent := filepath.Dir(finalPath)
|
||||
for parent != s.filesRoot {
|
||||
if err := os.Remove(parent); err != nil {
|
||||
return nil
|
||||
}
|
||||
parent = filepath.Dir(parent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
136
fstore/store-harness_test.go
Normal file
136
fstore/store-harness_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStoreHarness(t *testing.T) {
|
||||
StoreTestHarness{}.Run(t)
|
||||
}
|
||||
|
||||
type StoreTestHarness struct{}
|
||||
|
||||
func (h StoreTestHarness) Run(t *testing.T) {
|
||||
val := reflect.ValueOf(h)
|
||||
typ := val.Type()
|
||||
for i := 0; i < typ.NumMethod(); i++ {
|
||||
method := typ.Method(i)
|
||||
|
||||
if !strings.HasPrefix(method.Name, "Test") {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(method.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
|
||||
primary, err := Open(Config{
|
||||
RootDir: rootDir,
|
||||
Primary: true,
|
||||
WALSegMinCount: 1,
|
||||
WALSegMaxAgeSec: 1,
|
||||
WALSegGCAgeSec: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer primary.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/rep/", primary.Handle)
|
||||
testServer := httptest.NewServer(mux)
|
||||
defer testServer.Close()
|
||||
|
||||
rootDir2 := t.TempDir()
|
||||
secondary, err := Open(Config{
|
||||
RootDir: rootDir2,
|
||||
Primary: false,
|
||||
PrimaryEndpoint: testServer.URL + "/rep/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer secondary.Close()
|
||||
|
||||
val.MethodByName(method.Name).Call([]reflect.Value{
|
||||
reflect.ValueOf(t),
|
||||
reflect.ValueOf(primary),
|
||||
reflect.ValueOf(secondary),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (StoreTestHarness) TestBasic(t *testing.T, primary, secondary *Store) {
|
||||
stateChan := make(chan map[string]string, 1)
|
||||
go func() {
|
||||
stateChan <- primary.WriteRandomFor(4 * time.Second)
|
||||
}()
|
||||
|
||||
state := <-stateChan
|
||||
secondary.WaitForParity(primary)
|
||||
|
||||
primary.AssertState(t, state)
|
||||
secondary.AssertState(t, state)
|
||||
}
|
||||
|
||||
func (StoreTestHarness) TestWriteThenFollow(t *testing.T, primary, secondary *Store) {
|
||||
secondary.Close()
|
||||
stateChan := make(chan map[string]string, 1)
|
||||
go func() {
|
||||
stateChan <- primary.WriteRandomFor(4 * time.Second)
|
||||
}()
|
||||
|
||||
state := <-stateChan
|
||||
|
||||
var err error
|
||||
secondary, err = Open(secondary.conf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
secondary.WaitForParity(primary)
|
||||
|
||||
primary.AssertState(t, state)
|
||||
secondary.AssertState(t, state)
|
||||
}
|
||||
|
||||
func (StoreTestHarness) TestCloseAndOpenFollowerConcurrently(t *testing.T, primary, secondary *Store) {
|
||||
secondary.Close()
|
||||
stateChan := make(chan map[string]string, 1)
|
||||
go func() {
|
||||
stateChan <- primary.WriteRandomFor(8 * time.Second)
|
||||
}()
|
||||
|
||||
var err error
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
time.Sleep(time.Second)
|
||||
|
||||
secondary, err = Open(secondary.conf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
|
||||
secondary.Close()
|
||||
}
|
||||
|
||||
secondary, err = Open(secondary.conf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state := <-stateChan
|
||||
|
||||
secondary.WaitForParity(primary)
|
||||
|
||||
primary.AssertState(t, state)
|
||||
secondary.AssertState(t, state)
|
||||
}
|
||||
171
fstore/store-rep.go
Normal file
171
fstore/store-rep.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"git.crumpington.com/public/jldb/lib/errs"
|
||||
"git.crumpington.com/public/jldb/lib/wal"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) repSendState(conn net.Conn) error {
|
||||
err := filepath.Walk(s.filesRoot, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// Skip deleted files.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
relPath, err := filepath.Rel(s.filesRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(s.conf.NetTimeout))
|
||||
if err := binary.Write(conn, binary.LittleEndian, int32(len(relPath))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Write([]byte(relPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(conn, binary.LittleEndian, int64(info.Size())); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(s.conf.NetTimeout))
|
||||
if _, err := io.CopyN(conn, f, info.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(s.conf.NetTimeout))
|
||||
if err := binary.Write(conn, binary.LittleEndian, int32(0)); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) repRecvState(conn net.Conn) error {
|
||||
var (
|
||||
errorDone = errors.New("Done")
|
||||
pathLen = int32(0)
|
||||
fileSize = int64(0)
|
||||
pathBuf = make([]byte, 1024)
|
||||
)
|
||||
|
||||
for {
|
||||
|
||||
err := func() error {
|
||||
conn.SetReadDeadline(time.Now().Add(s.conf.NetTimeout))
|
||||
if err := binary.Read(conn, binary.LittleEndian, &pathLen); err != nil {
|
||||
return err
|
||||
}
|
||||
if pathLen == 0 {
|
||||
return errorDone
|
||||
}
|
||||
|
||||
if cap(pathBuf) < int(pathLen) {
|
||||
pathBuf = make([]byte, pathLen)
|
||||
}
|
||||
pathBuf = pathBuf[:pathLen]
|
||||
|
||||
if _, err := io.ReadFull(conn, pathBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(s.filesRoot, string(pathBuf))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Read(conn, binary.LittleEndian, &fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(s.conf.NetTimeout))
|
||||
if _, err = io.CopyN(f, conn, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.Sync()
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
if err == errorDone {
|
||||
return nil
|
||||
}
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) repInitStorage() (err error) {
|
||||
if err := os.MkdirAll(s.filesRoot, 0700); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
if err := os.MkdirAll(s.tmpDir, 0700); err != nil {
|
||||
return errs.IO.WithErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) repReplay(rec wal.Record) (err error) {
|
||||
cmd := command{}
|
||||
if err := cmd.ReadFrom(rec.Reader); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Store {
|
||||
return s.applyStoreFromReader(cmd)
|
||||
}
|
||||
return s.applyRemove(cmd)
|
||||
}
|
||||
|
||||
func (s *Store) repLoadFromStorage() (err error) {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) repApply(rec wal.Record) (err error) {
|
||||
cmd := command{}
|
||||
if err := cmd.ReadFrom(rec.Reader); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd.Store {
|
||||
if s.conf.Primary {
|
||||
return s.applyStoreFromTempID(cmd)
|
||||
}
|
||||
return s.applyStoreFromReader(cmd)
|
||||
}
|
||||
return s.applyRemove(cmd)
|
||||
}
|
||||
119
fstore/store-testrunner_test.go
Normal file
119
fstore/store-testrunner_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StoreTestCase struct {
|
||||
Name string
|
||||
Update func(t *testing.T, s *Store) error
|
||||
ExpectedError error
|
||||
State map[string]string
|
||||
}
|
||||
|
||||
func TestRunnerTestCases(t *testing.T) {
|
||||
t.Helper()
|
||||
rootDir := t.TempDir()
|
||||
|
||||
store, err := Open(Config{
|
||||
RootDir: rootDir,
|
||||
Primary: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/rep/", store.Handle)
|
||||
testServer := httptest.NewServer(mux)
|
||||
defer testServer.Close()
|
||||
|
||||
rootDir2 := t.TempDir()
|
||||
secondary, err := Open(Config{
|
||||
RootDir: rootDir2,
|
||||
Primary: false,
|
||||
PrimaryEndpoint: testServer.URL + "/rep/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer secondary.Close()
|
||||
|
||||
for _, testCase := range storeTestCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.Name, func(t *testing.T) {
|
||||
testRunnerRunTestCase(t, store, secondary, testCase)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testRunnerRunTestCase(t *testing.T, store, secondary *Store, testCase StoreTestCase) {
|
||||
err := testCase.Update(t, store)
|
||||
if !errors.Is(err, testCase.ExpectedError) {
|
||||
t.Fatal(testCase.Name, err, testCase.ExpectedError)
|
||||
}
|
||||
|
||||
store.AssertState(t, testCase.State)
|
||||
|
||||
pInfo := store.rep.Info()
|
||||
for {
|
||||
sInfo := secondary.rep.Info()
|
||||
if sInfo.AppSeqNum == pInfo.AppSeqNum {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
secondary.AssertState(t, testCase.State)
|
||||
}
|
||||
|
||||
var storeTestCases = []StoreTestCase{
|
||||
{
|
||||
Name: "store a file",
|
||||
Update: func(t *testing.T, s *Store) error {
|
||||
return s.StoreString("hello world", "/a/b/c")
|
||||
},
|
||||
ExpectedError: nil,
|
||||
State: map[string]string{
|
||||
"/a/b/c": "hello world",
|
||||
},
|
||||
}, {
|
||||
Name: "store more files",
|
||||
Update: func(t *testing.T, s *Store) error {
|
||||
if err := s.StoreString("good bye", "/a/b/x"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.StoreString("xxx", "/x")
|
||||
},
|
||||
ExpectedError: nil,
|
||||
State: map[string]string{
|
||||
"/a/b/c": "hello world",
|
||||
"/a/b/x": "good bye",
|
||||
"/x": "xxx",
|
||||
},
|
||||
}, {
|
||||
Name: "remove a file",
|
||||
Update: func(t *testing.T, s *Store) error {
|
||||
return s.Remove("/x")
|
||||
},
|
||||
ExpectedError: nil,
|
||||
State: map[string]string{
|
||||
"/a/b/c": "hello world",
|
||||
"/a/b/x": "good bye",
|
||||
},
|
||||
}, {
|
||||
Name: "remove another file",
|
||||
Update: func(t *testing.T, s *Store) error {
|
||||
return s.Remove("/a/b/c")
|
||||
},
|
||||
ExpectedError: nil,
|
||||
State: map[string]string{
|
||||
"/a/b/x": "good bye",
|
||||
},
|
||||
},
|
||||
}
|
||||
279
fstore/store.go
Normal file
279
fstore/store.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"git.crumpington.com/public/jldb/lib/errs"
|
||||
"git.crumpington.com/public/jldb/lib/idgen"
|
||||
"git.crumpington.com/public/jldb/lib/rep"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RootDir string
|
||||
Primary bool
|
||||
ReplicationPSK string
|
||||
NetTimeout time.Duration // Default is 1 minute.
|
||||
|
||||
// WAL settings.
|
||||
WALSegMinCount int64 // Minimum Change sets in a segment. Default is 1024.
|
||||
WALSegMaxAgeSec int64 // Maximum age of a segment. Default is 1 hour.
|
||||
WALSegGCAgeSec int64 // Segment age for garbage collection. Default is 7 days.
|
||||
|
||||
// For use by secondary.
|
||||
PrimaryEndpoint string
|
||||
}
|
||||
|
||||
func (c Config) repConfig() rep.Config {
|
||||
return rep.Config{
|
||||
RootDir: repDirPath(c.RootDir),
|
||||
Primary: c.Primary,
|
||||
ReplicationPSK: c.ReplicationPSK,
|
||||
NetTimeout: c.NetTimeout,
|
||||
WALSegMinCount: c.WALSegMinCount,
|
||||
WALSegMaxAgeSec: c.WALSegMaxAgeSec,
|
||||
WALSegGCAgeSec: c.WALSegGCAgeSec,
|
||||
PrimaryEndpoint: c.PrimaryEndpoint,
|
||||
SynchronousAppend: true,
|
||||
}
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
lock sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
rep *rep.Replicator
|
||||
conf Config
|
||||
filesRoot string // Absolute, no trailing slash.
|
||||
tmpDir string // Absolute, no trailing slash.
|
||||
}
|
||||
|
||||
func Open(conf Config) (*Store, error) {
|
||||
if conf.NetTimeout <= 0 {
|
||||
conf.NetTimeout = time.Minute
|
||||
}
|
||||
|
||||
s := &Store{
|
||||
buf: &bytes.Buffer{},
|
||||
conf: conf,
|
||||
filesRoot: filesRootPath(conf.RootDir),
|
||||
tmpDir: tempDirPath(conf.RootDir),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
repConf := s.conf.repConfig()
|
||||
|
||||
s.rep, err = rep.Open(
|
||||
rep.App{
|
||||
SendState: s.repSendState,
|
||||
RecvState: s.repRecvState,
|
||||
InitStorage: s.repInitStorage,
|
||||
Replay: s.repReplay,
|
||||
LoadFromStorage: s.repLoadFromStorage,
|
||||
Apply: s.repApply,
|
||||
},
|
||||
repConf)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTempFilePath() string {
|
||||
return tempFilePath(s.tmpDir, idgen.Next())
|
||||
}
|
||||
|
||||
func (s *Store) StoreString(str string, finalPath string) error {
|
||||
return s.StoreBytes([]byte(str), finalPath)
|
||||
}
|
||||
|
||||
func (s *Store) StoreBytes(b []byte, finalPath string) error {
|
||||
tmpPath := s.GetTempFilePath()
|
||||
|
||||
if err := os.WriteFile(tmpPath, b, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpPath)
|
||||
|
||||
return s.Store(tmpPath, finalPath)
|
||||
}
|
||||
|
||||
func (s *Store) Store(tmpPath, finalPath string) error {
|
||||
if !s.conf.Primary {
|
||||
return errs.NotAuthorized.WithMsg("not primary")
|
||||
}
|
||||
|
||||
userPath, _, err := s.cleanAndValidatePath(finalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idStr := filepath.Base(tmpPath)
|
||||
tmpID, _ := strconv.ParseUint(idStr, 10, 64)
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
f, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := command{
|
||||
Path: userPath,
|
||||
Store: true,
|
||||
TempID: tmpID,
|
||||
FileSize: fi.Size(),
|
||||
File: f,
|
||||
}
|
||||
|
||||
size, reader := cmd.Reader(s.buf)
|
||||
|
||||
_, _, err = s.rep.Append(size, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Remove(filePath string) error {
|
||||
if !s.conf.Primary {
|
||||
return errs.NotAuthorized.WithMsg("not primary")
|
||||
}
|
||||
|
||||
userPath, _, err := s.cleanAndValidatePath(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
cmd := command{
|
||||
Path: userPath,
|
||||
Store: false,
|
||||
TempID: 0,
|
||||
FileSize: 0,
|
||||
}
|
||||
|
||||
size, reader := cmd.Reader(s.buf)
|
||||
|
||||
_, _, err = s.rep.Append(size, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) List(p string) (dirs, files []string, err error) {
|
||||
_, fullPath, err := s.cleanAndValidatePath(p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !fi.IsDir() {
|
||||
return nil, []string{fi.Name()}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, e.Name())
|
||||
} else {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return dirs, files, nil
|
||||
}
|
||||
|
||||
func (s *Store) Stat(p string) (os.FileInfo, error) {
|
||||
_, fullPath, err := s.cleanAndValidatePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
func (s *Store) WriteTo(w io.Writer, filePath string) error {
|
||||
_, fullPath, err := s.cleanAndValidatePath(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Serve(w http.ResponseWriter, r *http.Request, p string) {
|
||||
_, fullPath, err := s.cleanAndValidatePath(p)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, fullPath)
|
||||
}
|
||||
|
||||
func (s *Store) ServeFallback(w http.ResponseWriter, r *http.Request, paths ...string) {
|
||||
for _, p := range paths {
|
||||
_, fullPath, err := s.cleanAndValidatePath(p)
|
||||
if err == nil {
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err == nil && !fi.IsDir() {
|
||||
http.ServeFile(w, r, fullPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (s *Store) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
s.rep.Handle(w, r)
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.rep.Close()
|
||||
}
|
||||
|
||||
func (s *Store) cleanAndValidatePath(in string) (userPath, fullPath string, err error) {
|
||||
userPath = cleanPath(in)
|
||||
if err := validatePath(userPath); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return userPath, filepath.Join(s.filesRoot, userPath), nil
|
||||
}
|
||||
91
fstore/store_test.go
Normal file
91
fstore/store_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) ReadString(t *testing.T, filePath string) string {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := s.WriteTo(buf, filePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (s *Store) AssertState(t *testing.T, in map[string]string) {
|
||||
state := NewStoreState(in)
|
||||
s.AssertStateDir(t, state)
|
||||
}
|
||||
|
||||
func (s *Store) AssertStateDir(t *testing.T, dir StoreState) {
|
||||
dirs, files, err := s.List(dir.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check file lengths.
|
||||
if len(files) != len(dir.Files) {
|
||||
t.Fatal(files, dir.Files)
|
||||
}
|
||||
|
||||
// check dir lengths.
|
||||
if len(dirs) != len(dir.Dirs) {
|
||||
t.Fatal(dirs, dir.Dirs)
|
||||
}
|
||||
|
||||
for _, file := range dir.Files {
|
||||
expectedContent := file.FileData
|
||||
actualContent := s.ReadString(t, file.Path)
|
||||
if expectedContent != actualContent {
|
||||
t.Fatal(expectedContent, actualContent)
|
||||
}
|
||||
}
|
||||
|
||||
for _, dir := range dir.Dirs {
|
||||
s.AssertStateDir(t, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) WriteRandomFor(dt time.Duration) map[string]string {
|
||||
state := map[string]string{}
|
||||
tStart := time.Now()
|
||||
for time.Since(tStart) < dt {
|
||||
slug1 := strconv.FormatInt(rand.Int63n(10), 10)
|
||||
slug2 := strconv.FormatInt(rand.Int63n(10), 10)
|
||||
|
||||
path := filepath.Join("/", slug1, slug2)
|
||||
|
||||
if rand.Float32() < 0.05 {
|
||||
if err := s.Remove(path); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
delete(state, path)
|
||||
} else {
|
||||
data := randString()
|
||||
state[path] = data
|
||||
if err := s.StoreString(data, path); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
func (s *Store) WaitForParity(rhs *Store) {
|
||||
for {
|
||||
i1 := s.rep.Info()
|
||||
i2 := rhs.rep.Info()
|
||||
if i1.AppSeqNum == i2.AppSeqNum {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
6
fstore/templates/page.html
Normal file
6
fstore/templates/page.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<pre>{{.}}</pre>
|
||||
</body>
|
||||
</html>
|
||||
16
fstore/test_util.go
Normal file
16
fstore/test_util.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package fstore
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"encoding/base32"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func randString() string {
|
||||
size := 8 + rand.Intn(92)
|
||||
buf := make([]byte, size)
|
||||
if _, err := crand.Read(buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base32.StdEncoding.EncodeToString(buf)
|
||||
}
|
||||
Reference in New Issue
Block a user