Initial commit

Signed-off-by: Alex Vanin <a.vanin@yadro.com>
This commit is contained in:
Alexey Vanin 2024-07-10 14:15:59 +03:00
commit bedb008081
7 changed files with 344 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
bin/*

3
Makefile Normal file
View file

@ -0,0 +1,3 @@
.PHONY: build
build:
CGO_ENABLED=0 go build -o ./bin/chi-s3-vhs ./

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# Dynamic S3 address-style routing prototype
Prototype of dynamic routing for path-style and virtual-hosted-style requests
implemented with go-chi framework.
## Build
```
$ make
```
## Run
### Default setting.
VHS is disabled. VHS can be overridden by HTTP Header.
```
$ ./bin/chi-s3-vhs
$ curl bucketA.localhost:44412/bucketB/obj
bucket:[bucketB] object:[obj]
$ curl -H "X-FrostFS-VHS: enabled" bucketA.localhost:44412/bucketB/obj
bucket:[bucketA] object:[bucketB/obj]
```
### Global VHS
VHS can be overridden by HTTP Header
```
$ ./bin/chi-s3-vhs --global
$ curl bucketA.localhost:44412/bucketB/obj
bucket:[bucketA] object:[bucketB/obj]
$ curl -H "X-FrostFS-VHS: disabled" bucketA.localhost:44412/bucketB/obj
bucket:[bucketB] object:[obj]
```
### Namespace override
Namespace setting may override default value, but not header value
```
$ ./chi-s3-vhs --global --ns foo=disabled
$ curl bucketA.localhost:44412/bucketB/obj
bucket:[bucketA] object:[bucketB/obj]
$ curl -H "X-FrostFS-Namespace: foo" bucketA.localhost:44412/bucketB/obj
bucket:[bucketB] object:[obj]
$ curl -H "X-FrostFS-Namespace: foo" -H "X-FrostFS-VHS: enabled" bucketA.localhost:44412/bucketB/obj
bucket:[bucketA] object:[bucketB/obj]
```

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module chi-s3-vhs
go 1.22.5
require github.com/go-chi/chi/v5 v5.1.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=

134
main.go Normal file
View file

@ -0,0 +1,134 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"github.com/go-chi/chi/v5"
)
type AddressTypeRouter struct {
vhsGlobalEnabled bool // setting from gateway application
vhsNamespaceEnabled map[string]bool // setting from gateway application
pathStyleRouter chi.Router // look for 'defaultRouter' in S3 Gateway
vhsRouter chi.Router // look for 'bucketRouter' in S3 Gateway
}
type nsSettings struct {
settings map[string]bool
}
func (s *nsSettings) Set(value string) error {
v := strings.Split(value, "=")
if len(v) != 2 {
return fmt.Errorf("invalid argument: %s", value)
}
switch v[1] {
case "enabled":
s.settings[v[0]] = true
case "disabled":
s.settings[v[0]] = false
}
return nil
}
func (s *nsSettings) String() string {
return fmt.Sprintf("%v", s.settings)
}
func main() {
addr := flag.String("a", ":44412", "http listen address")
globalVHSEnabled := flag.Bool("global", false, "enable global VHS")
namespaceVHSEnabled := nsSettings{
settings: make(map[string]bool),
}
flag.Var(&namespaceVHSEnabled, "ns", "namespace settings")
flag.Parse()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
objRouter := ObjectRouter() // router for object operations
bktRouter := BucketRouter(objRouter) // router for bucket operations
pathStyleRouter := chi.NewRouter() // router for path-style requests
pathStyleRouter.Mount("/{bucket}", bktRouter)
atr := AddressTypeRouter{ // router for path-style and virtual-host-style switch
vhsGlobalEnabled: *globalVHSEnabled,
vhsNamespaceEnabled: namespaceVHSEnabled.settings,
pathStyleRouter: pathStyleRouter,
vhsRouter: bktRouter,
}
api := chi.NewRouter() // global router
api.Use(Request())
api.Mount("/", atr)
srv := &http.Server{}
srv.Handler = api
l, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatal(fmt.Sprintf("failed to listen on %s: %v", *addr, err))
}
defer l.Close()
go srv.Serve(l)
log.Printf("listening on %s", *addr)
log.Printf("global VHS enabled: %v", *globalVHSEnabled)
log.Printf("namespace override: %s", &namespaceVHSEnabled)
<-ctx.Done()
}
func (atr AddressTypeRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
namespace := r.Header.Get("X-FrostFS-Namespace")
router := atr.pathStyleRouter
vhsEnabled := atr.isVHSAddress(r, namespace)
if vhsEnabled {
log.Println("VHS is enabled")
router = atr.vhsRouter
bucketName := atr.vhsBucketName(r)
if rctx := chi.RouteContext(r.Context()); rctx != nil {
rctx.URLParams.Add("bucket", bucketName)
}
}
router.ServeHTTP(w, r)
}
func (atr AddressTypeRouter) isVHSAddress(r *http.Request, namespace string) bool {
result := atr.vhsGlobalEnabled // default global value
// check namespace override
if v, ok := atr.vhsNamespaceEnabled[namespace]; ok {
result = v
}
// check header override
switch r.Header.Get("X-FrostFS-VHS") {
case "enabled":
result = true
case "disabled":
result = false
default:
}
return result
}
func (atr AddressTypeRouter) vhsBucketName(r *http.Request) string {
hostname := r.Host
if r.URL.IsAbs() {
hostname = r.URL.Hostname()
}
parts := strings.Split(hostname, ".")
return parts[0]
}

142
s3.go Normal file
View file

@ -0,0 +1,142 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
)
type (
Func func(h http.Handler) http.Handler
ReqInfo struct {
BucketName string
ObjectName string
}
)
const ctxRequestInfo = "FrostFS-S3-GW"
func GetReqInfo(ctx context.Context) *ReqInfo {
if ctx == nil {
return &ReqInfo{}
} else if r, ok := ctx.Value(ctxRequestInfo).(*ReqInfo); ok {
return r
}
return &ReqInfo{}
}
func SetReqInfo(ctx context.Context, req *ReqInfo) context.Context {
if ctx == nil {
return nil
}
return context.WithValue(ctx, ctxRequestInfo, req)
}
func AddBucketName() Func {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
reqInfo := GetReqInfo(ctx)
reqInfo.BucketName = chi.URLParam(r, "bucket")
h.ServeHTTP(w, r)
})
}
}
// AddObjectName adds objects name to ReqInfo from context.
func AddObjectName() Func {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
reqInfo := GetReqInfo(ctx)
rctx := chi.RouteContext(ctx)
// trim leading slash (always present)
reqInfo.ObjectName = rctx.RoutePath[1:]
if r.URL.RawPath != "" {
// we have to do this because of
// https://github.com/go-chi/chi/issues/641
// https://github.com/go-chi/chi/issues/642
if obj, err := url.PathUnescape(reqInfo.ObjectName); err != nil {
// log error
} else {
reqInfo.ObjectName = obj
}
}
h.ServeHTTP(w, r)
})
}
}
func Request() Func {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqInfo := &ReqInfo{}
r = r.WithContext(SetReqInfo(r.Context(), reqInfo))
h.ServeHTTP(w, r)
})
}
}
func BucketRouter(objRouter chi.Router) chi.Router {
bktRouter := chi.NewRouter()
bktRouter.Use(AddBucketName())
bktRouter.Mount("/", objRouter)
bktRouter.Group(func(r chi.Router) {
r.MethodFunc(http.MethodGet, "/", BucketHandler())
})
return bktRouter
}
func ObjectRouter() chi.Router {
objRouter := chi.NewRouter()
objRouter.Use(AddObjectName())
objRouter.Group(func(r chi.Router) {
r.MethodFunc(http.MethodGet, "/*", ObjectHandler())
})
return objRouter
}
func ObjectHandler() http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
log.Println("object handler is triggered")
ri := GetReqInfo(r.Context())
if ri == nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(fmt.Sprintf("bucket:[%s] object:[%s]\n", ri.BucketName, ri.ObjectName)))
}
}
func BucketHandler() http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
log.Printf("bucket handler is triggered")
ri := GetReqInfo(r.Context())
if ri == nil {
log.Println("can't find request info")
rw.WriteHeader(http.StatusInternalServerError)
return
}
log.Printf("bucket:[%s]", ri.BucketName)
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(fmt.Sprintf("bucket:[%s]\n", ri.BucketName)))
}
}