v0.5: (BREAKING) replacing path with path_patterns, improved docker monitoring mechanism, bug fixes

This commit is contained in:
yusing
2024-09-16 13:05:04 +08:00
parent 2e7ba51521
commit 7a0478164f
20 changed files with 252 additions and 246 deletions

View File

@@ -6,7 +6,6 @@ import (
U "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/config"
PT "github.com/yusing/go-proxy/proxy/fields"
R "github.com/yusing/go-proxy/route"
)
@@ -24,17 +23,7 @@ func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.ErrNotFound("target", target), http.StatusNotFound)
return
case *R.HTTPRoute:
path, err := PT.NewPath(r.FormValue("path"))
if err.IsNotNil() {
U.HandleErr(w, r, err, http.StatusBadRequest)
return
}
sr, hasSr := route.GetSubroute(path)
if !hasSr {
U.HandleErr(w, r, U.ErrNotFound("path", string(path)), http.StatusNotFound)
return
}
ok = U.IsSiteHealthy(sr.TargetURL.String())
ok = U.IsSiteHealthy(route.TargetURL.String())
case *R.StreamRoute:
ok = U.IsStreamHealthy(
string(route.Scheme.ProxyScheme),

View File

@@ -1,32 +1,37 @@
package docker
import (
"net/http"
"strings"
E "github.com/yusing/go-proxy/error"
"gopkg.in/yaml.v3"
)
func yamlParser[T any](value string) (any, E.NestedError) {
var data T
func yamlListParser(value string) (any, E.NestedError) {
value = strings.TrimSpace(value)
if value == "" {
return []string{}, E.Nil()
}
var data []string
err := E.From(yaml.Unmarshal([]byte(value), &data))
return data, err
}
func setHeadersParser(value string) (any, E.NestedError) {
func yamlStringMappingParser(value string) (any, E.NestedError) {
value = strings.TrimSpace(value)
lines := strings.Split(value, "\n")
h := make(http.Header)
h := make(map[string]string)
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return nil, E.Invalid("set header statement", line)
}
key := strings.TrimSpace(parts[0])
vals := strings.Split(parts[1], ",")
for i := range vals {
h.Add(key, strings.TrimSpace(vals[i]))
val := strings.TrimSpace(parts[1])
if existing, ok := h[key]; ok {
h[key] = existing + ", " + val
} else {
h[key] = val
}
}
return h, E.Nil()
@@ -56,8 +61,9 @@ const NSProxy = "proxy"
var _ = func() int {
RegisterNamespace(NSProxy, ValueParserMap{
"aliases": commaSepParser,
"set_headers": setHeadersParser,
"hide_headers": yamlParser[[]string],
"path_patterns": yamlListParser,
"set_headers": yamlStringMappingParser,
"hide_headers": yamlListParser,
"no_tls_verify": boolParser,
})
return 0

View File

@@ -2,7 +2,6 @@ package docker
import (
"fmt"
"net/http"
"reflect"
"strings"
"testing"
@@ -82,26 +81,22 @@ X-Custom-Header1: foo, bar
X-Custom-Header1: baz
X-Custom-Header2: boo`
v = strings.TrimPrefix(v, "\n")
h := make(http.Header, 0)
h.Set("X-Custom-Header1", "foo")
h.Add("X-Custom-Header1", "bar")
h.Add("X-Custom-Header1", "baz")
h.Set("X-Custom-Header2", "boo")
h := map[string]string{
"X-Custom-Header1": "foo, bar, baz",
"X-Custom-Header2": "boo",
}
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "set_headers"), v)
if err.IsNotNil() {
t.Errorf("expected err=nil, got %s", err.Error())
}
hGot, ok := pl.Value.(http.Header)
hGot, ok := pl.Value.(map[string]string)
if !ok {
t.Error("value is not http.Header")
t.Errorf("value is not a map[string]string, but %T", pl.Value)
return
}
for k, vWant := range h {
vGot := hGot[k]
if !reflect.DeepEqual(vGot, vWant) {
t.Errorf("expected %s=%q, got %q", k, vWant, vGot)
}
if !reflect.DeepEqual(h, hGot) {
t.Errorf("expected %v, got %v", h, hGot)
}
}

View File

@@ -20,7 +20,6 @@ import (
R "github.com/yusing/go-proxy/route"
"github.com/yusing/go-proxy/server"
F "github.com/yusing/go-proxy/utils/functional"
W "github.com/yusing/go-proxy/watcher"
)
func main() {
@@ -70,7 +69,6 @@ func main() {
onShutdown.Add(func() {
docker.CloseAllClients()
W.StopAllFileWatchers()
cfg.Dispose()
})

View File

@@ -1,7 +1,6 @@
package model
import (
"net/http"
"strings"
F "github.com/yusing/go-proxy/utils/functional"
@@ -9,14 +8,14 @@ import (
type (
ProxyEntry struct {
Alias string `yaml:"-" json:"-"`
Scheme string `yaml:"scheme" json:"scheme"`
Host string `yaml:"host" json:"host"`
Port string `yaml:"port" json:"port"`
NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // http proxy only
Path string `yaml:"path" json:"path"` // http proxy only
SetHeaders http.Header `yaml:"set_headers" json:"set_headers"` // http proxy only
HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http proxy only
Alias string `yaml:"-" json:"-"`
Scheme string `yaml:"scheme" json:"scheme"`
Host string `yaml:"host" json:"host"`
Port string `yaml:"port" json:"port"`
NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // https proxy only
PathPatterns []string `yaml:"path_patterns" json:"path_patterns"` // http(s) proxy only
SetHeaders map[string]string `yaml:"set_headers" json:"set_headers"` // http(s) proxy only
HideHeaders []string `yaml:"hide_headers" json:"hide_headers"` // http(s) proxy only
}
ProxyEntries = *F.Map[string, *ProxyEntry]
@@ -37,8 +36,8 @@ func (e *ProxyEntry) SetDefaults() {
}
}
}
if e.Path == "" {
e.Path = "/"
if e.Host == "" {
e.Host = "localhost"
}
switch e.Scheme {
case "http":

View File

@@ -12,15 +12,15 @@ import (
type (
Entry struct { // real model after validation
Alias T.Alias
Scheme T.Scheme
Host T.Host
Port T.Port
URL *url.URL
NoTLSVerify bool
Path T.Path
SetHeaders http.Header
HideHeaders []string
Alias T.Alias
Scheme T.Scheme
Host T.Host
Port T.Port
URL *url.URL
NoTLSVerify bool
PathPatterns T.PathPatterns
SetHeaders http.Header
HideHeaders []string
}
StreamEntry struct {
Alias T.Alias `json:"alias"`
@@ -51,7 +51,11 @@ func validateEntry(m *M.ProxyEntry, s T.Scheme) (*Entry, E.NestedError) {
if err.IsNotNil() {
return nil, err
}
path, err := T.NewPath(m.Path)
pathPatterns, err := T.NewPathPatterns(m.PathPatterns)
if err.IsNotNil() {
return nil, err
}
setHeaders, err := T.NewHTTPHeaders(m.SetHeaders)
if err.IsNotNil() {
return nil, err
}
@@ -60,15 +64,15 @@ func validateEntry(m *M.ProxyEntry, s T.Scheme) (*Entry, E.NestedError) {
return nil, err
}
return &Entry{
Alias: T.NewAlias(m.Alias),
Scheme: s,
Host: host,
Port: port,
URL: url,
NoTLSVerify: m.NoTLSVerify,
Path: path,
SetHeaders: m.SetHeaders,
HideHeaders: m.HideHeaders,
Alias: T.NewAlias(m.Alias),
Scheme: s,
Host: host,
Port: port,
URL: url,
NoTLSVerify: m.NoTLSVerify,
PathPatterns: pathPatterns,
SetHeaders: setHeaders,
HideHeaders: m.HideHeaders,
}, E.Nil()
}

View File

@@ -0,0 +1,19 @@
package fields
import (
"net/http"
"strings"
E "github.com/yusing/go-proxy/error"
)
func NewHTTPHeaders(headers map[string]string) (http.Header, E.NestedError) {
h := make(http.Header)
for k, v := range headers {
vSplit := strings.Split(v, ",")
for _, header := range vSplit {
h.Add(k, strings.TrimSpace(header))
}
}
return h, E.Nil()
}

View File

@@ -1,14 +0,0 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
)
type Path string
func NewPath(s string) (Path, E.NestedError) {
if s == "" || s[0] == '/' {
return Path(s), E.Nil()
}
return "", E.Invalid("path", s).With("must be empty or start with '/'")
}

View File

@@ -0,0 +1,37 @@
package fields
import (
"regexp"
E "github.com/yusing/go-proxy/error"
)
type PathPattern string
type PathPatterns = []PathPattern
func NewPathPattern(s string) (PathPattern, E.NestedError) {
if len(s) == 0 {
return "", E.Invalid("path", "must not be empty")
}
if !pathPattern.MatchString(string(s)) {
return "", E.Invalid("path pattern", s)
}
return PathPattern(s), E.Nil()
}
func NewPathPatterns(s []string) (PathPatterns, E.NestedError) {
if len(s) == 0 {
return []PathPattern{"/"}, E.Nil()
}
pp := make(PathPatterns, len(s))
for i, v := range s {
if pattern, err := NewPathPattern(v); err.IsNotNil() {
return nil, err
} else {
pp[i] = pattern
}
}
return pp, E.Nil()
}
var pathPattern = regexp.MustCompile("^((GET|POST|DELETE|PUT|PATCH|HEAD|OPTIONS|CONNECT)\\s)?(/\\w*)+/?$")

View File

@@ -107,6 +107,7 @@ func (p *Provider) StartAllRoutes() E.NestedError {
func (p *Provider) StopAllRoutes() E.NestedError {
if p.watcherCancel != nil {
p.watcherCancel()
p.watcherCancel = nil
}
errors := E.NewBuilder("errors stopping routes for provider %q", p.name)
nStopped := 0
@@ -126,17 +127,9 @@ func (p *Provider) StopAllRoutes() E.NestedError {
func (p *Provider) ReloadRoutes() {
defer p.l.Info("routes reloaded")
select {
case p.reloadReqCh <- struct{}{}:
defer func() {
<-p.reloadReqCh
}()
p.StopAllRoutes()
p.loadRoutes()
p.StartAllRoutes()
default:
return
}
p.StopAllRoutes()
p.loadRoutes()
p.StartAllRoutes()
}
func (p *Provider) GetCurrentRoutes() *R.Routes {
@@ -149,13 +142,14 @@ func (p *Provider) watchEvents() {
for {
select {
case <-p.reloadReqCh:
case <-p.reloadReqCh: // block until last reload is done
p.ReloadRoutes()
continue // ignore events once after reload
case event, ok := <-events:
if !ok {
return
}
l.Infof("watcher event: %s", event)
l.Info(event)
p.reloadReqCh <- struct{}{}
case err, ok := <-errs:
if !ok {

View File

@@ -19,23 +19,18 @@ import (
type (
HTTPRoute struct {
Alias PT.Alias `json:"alias"`
Subroutes HTTPSubroutes `json:"subroutes"`
Alias PT.Alias `json:"alias"`
mux *http.ServeMux
TargetURL URL
PathPatterns PT.PathPatterns
mux *http.ServeMux
handler *P.ReverseProxy
}
HTTPSubroute struct {
TargetURL *URL `json:"targetURL"`
Path PathKey `json:"path"`
proxy *P.ReverseProxy
}
URL url.URL
PathKey = PT.Path
SubdomainKey = PT.Alias
HTTPSubroutes = map[PathKey]HTTPSubroute
URL url.URL
PathKey = PT.PathPattern
SubdomainKey = PT.Alias
)
var httpRoutes = F.NewMap[SubdomainKey, *HTTPRoute]()
@@ -57,49 +52,28 @@ func NewHTTPRoute(entry *P.Entry) (*HTTPRoute, E.NestedError) {
r, ok := httpRoutes.UnsafeGet(entry.Alias)
if !ok {
r = &HTTPRoute{
Alias: entry.Alias,
Subroutes: make(HTTPSubroutes),
mux: http.NewServeMux(),
Alias: entry.Alias,
TargetURL: URL(*entry.URL),
PathPatterns: entry.PathPatterns,
handler: rp,
}
httpRoutes.UnsafeSet(entry.Alias, r)
}
path := entry.Path
if _, exists := r.Subroutes[path]; exists {
return nil, E.Duplicated("path", path)
}
r.mux.HandleFunc(string(path), rp.ServeHTTP)
if err := recover(); err != nil {
switch t := err.(type) {
case error:
// NOTE: likely path pattern error
return nil, E.From(t)
default:
return nil, E.From(fmt.Errorf("%v", t))
}
}
sr := HTTPSubroute{
TargetURL: (*URL)(entry.URL),
proxy: rp,
Path: path,
}
rewrite := rp.Rewrite
if logrus.GetLevel() == logrus.DebugLevel {
l := logrus.WithField("alias", entry.Alias)
sr.proxy.Rewrite = func(pr *P.ProxyRequest) {
rp.Rewrite = func(pr *P.ProxyRequest) {
l.Debug("request URL: ", pr.In.Host, pr.In.URL.Path)
l.Debug("request headers: ", pr.In.Header)
rewrite(pr)
}
} else {
sr.proxy.Rewrite = rewrite
rp.Rewrite = rewrite
}
r.Subroutes[path] = sr
return r, E.Nil()
}
@@ -108,20 +82,20 @@ func (r *HTTPRoute) String() string {
}
func (r *HTTPRoute) Start() E.NestedError {
r.mux = http.NewServeMux()
for _, p := range r.PathPatterns {
r.mux.HandleFunc(string(p), r.handler.ServeHTTP)
}
httpRoutes.Set(r.Alias, r)
return E.Nil()
}
func (r *HTTPRoute) Stop() E.NestedError {
r.mux = nil
httpRoutes.Delete(r.Alias)
return E.Nil()
}
func (r *HTTPRoute) GetSubroute(path PathKey) (HTTPSubroute, bool) {
sr, ok := r.Subroutes[path]
return sr, ok
}
func (u *URL) String() string {
return (*url.URL)(u).String()
}

View File

@@ -2,6 +2,7 @@ package watcher
import (
"context"
"fmt"
"time"
"github.com/docker/docker/api/types/events"
@@ -47,14 +48,28 @@ func (w *DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.Nest
for {
select {
case <-ctx.Done():
errCh <- E.From(<-cErrCh)
if err := <-cErrCh; err != nil {
errCh <- E.From(err)
}
return
case msg := <-cEventCh:
var Action Action
switch msg.Action {
case events.ActionStart:
Action = ActionCreated
case events.ActionDie:
Action = ActionDeleted
default: // NOTE: should not happen
Action = ActionModified
}
eventCh <- Event{
ActorName: msg.Actor.Attributes["name"],
Action: ActionModified,
ActorName: fmt.Sprintf("container %q", msg.Actor.Attributes["name"]),
Action: Action,
}
case err := <-cErrCh:
if err == nil {
continue
}
errCh <- E.From(err)
select {
case <-ctx.Done():
@@ -74,7 +89,7 @@ func (w *DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.Nest
}
var dwOptions = events.ListOptions{Filters: filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("event", "start"),
filters.Arg("event", "die"), // 'stop' already triggering 'die'
filters.Arg("type", string(events.ContainerEventType)),
filters.Arg("event", string(events.ActionStart)),
filters.Arg("event", string(events.ActionDie)), // 'stop' already triggering 'die'
)}

View File

@@ -12,8 +12,9 @@ type (
const (
ActionModified Action = "MODIFIED"
ActionDeleted Action = "DELETED"
ActionCreated Action = "CREATED"
ActionStarted Action = "STARTED"
ActionDeleted Action = "DELETED"
)
func (e Event) String() string {
@@ -23,11 +24,3 @@ func (e Event) String() string {
func (a Action) IsDelete() bool {
return a == ActionDeleted
}
func (a Action) IsModify() bool {
return a == ActionModified
}
func (a Action) IsCreate() bool {
return a == ActionCreated
}

View File

@@ -18,10 +18,6 @@ func NewFileWatcher(filename string) Watcher {
return &fileWatcher{filename: filename}
}
func StopAllFileWatchers() {
fwHelper.close()
}
func (f *fileWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.NestedError) {
return fwHelper.Add(ctx, f)
}

View File

@@ -80,13 +80,6 @@ func (h *fileWatcherHelper) Remove(w *fileWatcher) {
delete(h.m, w.filename)
}
// deinit closes the fs watcher
// and waits for the start() loop to finish
func (h *fileWatcherHelper) close() {
_ = h.w.Close()
h.wg.Wait() // wait for `start()` loop to finish
}
func (h *fileWatcherHelper) start() {
defer h.wg.Done()