fixed healthchecker start even if disabled, simplified label parsing

This commit is contained in:
yusing 2024-11-11 06:34:12 +08:00
parent 2951304647
commit c07f2ed722
11 changed files with 158 additions and 290 deletions

View file

@ -1,125 +1,51 @@
package docker package docker
import ( import (
"reflect"
"strings" "strings"
E "github.com/yusing/go-proxy/internal/error" E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
) )
/* type LabelMap = map[string]any
Formats:
- namespace.attribute
- namespace.target.attribute
- namespace.target.attribute.namespace2.attribute
*/
type (
Label struct {
Namespace string
Target string
Attribute string
Value any
}
NestedLabelMap map[string]U.SerializedObject
)
var ( func ParseLabels(labels map[string]string) (LabelMap, E.Error) {
ErrApplyToNil = E.New("label value is nil") nestedMap := make(LabelMap)
ErrFieldNotExist = E.New("field does not exist") errs := E.NewBuilder("labels error")
)
func (l *Label) String() string { for lbl, value := range labels {
if l.Attribute == "" { parts := strings.Split(lbl, ".")
return l.Namespace + "." + l.Target if parts[0] != NSProxy {
continue
} }
return l.Namespace + "." + l.Target + "." + l.Attribute if len(parts) == 1 {
errs.Add(E.Errorf("invalid label %s", lbl).Subject(lbl))
continue
} }
parts = parts[1:]
currentMap := nestedMap
// Apply applies the value of a Label to the corresponding field in the given object. for i, k := range parts {
// if i == len(parts)-1 {
// Parameters: // Last element, set the value
// - obj: a pointer to the object to which the Label value will be applied. currentMap[k] = value
// - l: a pointer to the Label containing the attribute and value to be applied.
//
// Returns:
// - error: an error if the field does not exist.
func ApplyLabel[T any](obj *T, l *Label) E.Error {
if obj == nil {
return ErrApplyToNil.Subject(l.String())
}
switch nestedLabel := l.Value.(type) {
case *Label:
var field reflect.Value
objType := reflect.TypeFor[T]()
for i := range reflect.TypeFor[T]().NumField() {
if objType.Field(i).Tag.Get("yaml") == l.Attribute {
field = reflect.ValueOf(obj).Elem().Field(i)
break
}
}
if !field.IsValid() {
return ErrFieldNotExist.Subject(l.Attribute).Subject(l.String())
}
dst, ok := field.Interface().(NestedLabelMap)
if !ok {
if field.Kind() == reflect.Ptr {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
}
} else { } else {
field = field.Addr() // If the key doesn't exist, create a new map
if _, exists := currentMap[k]; !exists {
currentMap[k] = make(LabelMap)
} }
err := U.Deserialize(U.SerializedObject{nestedLabel.Namespace: nestedLabel.Value}, field.Interface()) // Move deeper into the nested map
if err != nil { m, ok := currentMap[k].(LabelMap)
return err.Subject(l.String()) if !ok && currentMap[k] != "" {
errs.Add(E.Errorf("expect mapping, got %T", currentMap[k]).Subject(lbl))
continue
} else if !ok {
m = make(LabelMap)
currentMap[k] = m
} }
return nil currentMap = m
} }
if dst == nil {
field.Set(reflect.MakeMap(reflect.TypeFor[NestedLabelMap]()))
dst = field.Interface().(NestedLabelMap)
}
if dst[nestedLabel.Namespace] == nil {
dst[nestedLabel.Namespace] = make(U.SerializedObject)
}
dst[nestedLabel.Namespace][nestedLabel.Attribute] = nestedLabel.Value
return nil
default:
err := U.Deserialize(U.SerializedObject{l.Attribute: l.Value}, obj)
if err != nil {
return err.Subject(l.String())
}
return nil
} }
} }
func ParseLabel(label string, value string) *Label { return nestedMap, errs.Error()
parts := strings.Split(label, ".")
if len(parts) < 2 {
return &Label{
Namespace: label,
Value: value,
}
}
l := &Label{
Namespace: parts[0],
Target: parts[1],
Value: value,
}
switch len(parts) {
case 2:
l.Attribute = l.Target
case 3:
l.Attribute = parts[2]
default:
l.Attribute = parts[2]
nestedLabel := ParseLabel(strings.Join(parts[3:], "."), value)
l.Value = nestedLabel
}
return l
} }

View file

@ -1,79 +0,0 @@
package docker
import (
"fmt"
"testing"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
const (
mName = "middleware1"
mAttr = "prop1"
v = "value1"
)
func makeLabel(ns, name, attr string) string {
return fmt.Sprintf("%s.%s.%s", ns, name, attr)
}
func TestNestedLabel(t *testing.T) {
mAttr := "prop1"
lbl := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
sGot := ExpectType[*Label](t, lbl.Value)
ExpectFalse(t, sGot == nil)
ExpectEqual(t, sGot.Namespace, mName)
ExpectEqual(t, sGot.Attribute, mAttr)
}
func TestApplyNestedLabel(t *testing.T) {
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
lbl := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
err := ApplyLabel(entry, lbl)
ExpectNoError(t, err)
middleware1, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
got := ExpectType[string](t, middleware1[mAttr])
ExpectEqual(t, got, v)
}
func TestApplyNestedLabelExisting(t *testing.T) {
checkAttr := "prop2"
checkV := "value2"
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
entry.Middlewares = make(NestedLabelMap)
entry.Middlewares[mName] = make(U.SerializedObject)
entry.Middlewares[mName][checkAttr] = checkV
lbl := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
err := ApplyLabel(entry, lbl)
ExpectNoError(t, err)
middleware1, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
got := ExpectType[string](t, middleware1[mAttr])
ExpectEqual(t, got, v)
// check if prop2 is affected
ExpectFalse(t, middleware1[checkAttr] == nil)
got = ExpectType[string](t, middleware1[checkAttr])
ExpectEqual(t, got, checkV)
}
func TestApplyNestedLabelNoAttr(t *testing.T) {
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
entry.Middlewares = make(NestedLabelMap)
entry.Middlewares[mName] = make(U.SerializedObject)
lbl := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", "middlewares", mName)), v)
err := ApplyLabel(entry, lbl)
ExpectNoError(t, err)
_, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
}

View file

@ -4,7 +4,6 @@ const (
WildcardAlias = "*" WildcardAlias = "*"
NSProxy = "proxy" NSProxy = "proxy"
NSHomePage = "homepage"
LabelAliases = NSProxy + ".aliases" LabelAliases = NSProxy + ".aliases"
LabelExclude = NSProxy + ".exclude" LabelExclude = NSProxy + ".exclude"

View file

@ -19,8 +19,6 @@ type Entry interface {
} }
func ValidateEntry(m *RawEntry) (Entry, E.Error) { func ValidateEntry(m *RawEntry) (Entry, E.Error) {
m.FillMissingFields()
scheme, err := T.NewScheme(m.Scheme) scheme, err := T.NewScheme(m.Scheme)
if err != nil { if err != nil {
return nil, E.From(err) return nil, E.From(err)
@ -36,6 +34,9 @@ func ValidateEntry(m *RawEntry) (Entry, E.Error) {
if errs.HasError() { if errs.HasError() {
return nil, errs.Error() return nil, errs.Error()
} }
if !UseHealthCheck(entry) && (UseLoadBalance(entry) || UseIdleWatcher(entry)) {
return nil, E.New("healthCheck.disable cannot be true when loadbalancer or idlewatcher is enabled")
}
return entry, nil return entry, nil
} }

View file

@ -30,11 +30,13 @@ type (
PathPatterns []string `json:"path_patterns,omitempty" yaml:"path_patterns"` // http(s) proxy only PathPatterns []string `json:"path_patterns,omitempty" yaml:"path_patterns"` // http(s) proxy only
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck"` HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck"`
LoadBalance *loadbalancer.Config `json:"load_balance,omitempty" yaml:"load_balance"` LoadBalance *loadbalancer.Config `json:"load_balance,omitempty" yaml:"load_balance"`
Middlewares docker.NestedLabelMap `json:"middlewares,omitempty" yaml:"middlewares"` Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty" yaml:"middlewares"`
Homepage *homepage.Item `json:"homepage,omitempty" yaml:"homepage"` Homepage *homepage.Item `json:"homepage,omitempty" yaml:"homepage"`
/* Docker only */ /* Docker only */
Container *docker.Container `json:"container,omitempty" yaml:"-"` Container *docker.Container `json:"container,omitempty" yaml:"-"`
finalized bool
} }
RawEntries = F.Map[string, *RawEntry] RawEntries = F.Map[string, *RawEntry]
@ -42,7 +44,11 @@ type (
var NewProxyEntries = F.NewMapOf[string, *RawEntry] var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) FillMissingFields() { func (e *RawEntry) Finalize() {
if e.finalized {
return
}
isDocker := e.Container != nil isDocker := e.Container != nil
cont := e.Container cont := e.Container
if !isDocker { if !isDocker {
@ -124,14 +130,7 @@ func (e *RawEntry) FillMissingFields() {
} }
if e.HealthCheck == nil { if e.HealthCheck == nil {
e.HealthCheck = new(health.HealthCheckConfig) e.HealthCheck = health.DefaultHealthCheckConfig()
}
if e.HealthCheck.Interval == 0 {
e.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if e.HealthCheck.Timeout == 0 {
e.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
} }
if e.HealthCheck.Disable { if e.HealthCheck.Disable {
@ -159,6 +158,8 @@ func (e *RawEntry) FillMissingFields() {
e.Port = "0" e.Port = "0"
} }
} }
e.finalized = true
} }
func (e *RawEntry) splitPorts() (lp string, pp string, extra string) { func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
@ -168,10 +169,10 @@ func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
} else { } else {
lp = portSplit[0] lp = portSplit[0]
pp = portSplit[1] pp = portSplit[1]
}
if len(portSplit) > 2 { if len(portSplit) > 2 {
extra = strings.Join(portSplit[2:], ":") extra = strings.Join(portSplit[2:], ":")
} }
}
return return
} }

View file

@ -23,7 +23,7 @@ type ReverseProxyEntry struct { // real model after validation
PathPatterns fields.PathPatterns `json:"path_patterns,omitempty"` PathPatterns fields.PathPatterns `json:"path_patterns,omitempty"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalancer.Config `json:"load_balance,omitempty"` LoadBalance *loadbalancer.Config `json:"load_balance,omitempty"`
Middlewares docker.NestedLabelMap `json:"middlewares,omitempty"` Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"`
/* Docker only */ /* Docker only */
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`

View file

@ -32,7 +32,7 @@ func ValidatePathPattern(s string) (PathPattern, error) {
func ValidatePathPatterns(s []string) (PathPatterns, E.Error) { func ValidatePathPatterns(s []string) (PathPatterns, E.Error) {
if len(s) == 0 { if len(s) == 0 {
return []PathPattern{"/"}, nil return nil, nil
} }
errs := E.NewBuilder("invalid path patterns") errs := E.NewBuilder("invalid path patterns")
pp := make(PathPatterns, len(s)) pp := make(PathPatterns, len(s))

View file

@ -1,7 +1,6 @@
package provider package provider
import ( import (
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -12,6 +11,7 @@ import (
E "github.com/yusing/go-proxy/internal/error" E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/proxy/entry" "github.com/yusing/go-proxy/internal/proxy/entry"
"github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/route"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/internal/watcher" "github.com/yusing/go-proxy/internal/watcher"
) )
@ -22,13 +22,13 @@ type DockerProvider struct {
l zerolog.Logger l zerolog.Logger
} }
var ( const (
AliasRefRegex = regexp.MustCompile(`#\d+`) aliasRefPrefix = '#'
AliasRefRegexOld = regexp.MustCompile(`\$\d+`) aliasRefPrefixAlt = '$'
ErrAliasRefIndexOutOfRange = E.New("index out of range")
) )
var ErrAliasRefIndexOutOfRange = E.New("index out of range")
func DockerProviderImpl(name, dockerHost string, explicitOnly bool) (ProviderImpl, error) { func DockerProviderImpl(name, dockerHost string, explicitOnly bool) (ProviderImpl, error) {
if dockerHost == common.DockerHostFromEnv { if dockerHost == common.DockerHostFromEnv {
dockerHost = common.GetEnvString("DOCKER_HOST", client.DefaultDockerHost) dockerHost = common.GetEnvString("DOCKER_HOST", client.DefaultDockerHost)
@ -114,65 +114,70 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container)
} }
errs := E.NewBuilder("label errors") errs := E.NewBuilder("label errors")
for key, val := range container.Labels {
errs.Add(p.applyLabel(container, entries, key, val)) m, err := docker.ParseLabels(container.Labels)
errs.Add(err)
var wildcardProps docker.LabelMap
for alias, entryMapAny := range m {
if len(alias) == 0 {
errs.Add(E.New("empty alias"))
continue
} }
// remove all entries that failed to fill in missing fields var ok bool
entries.RangeAll(func(_ string, re *entry.RawEntry) { entryMap, ok := entryMapAny.(docker.LabelMap)
re.FillMissingFields() if !ok {
errs.Add(E.Errorf("expect mapping, got %T", entryMap).Subject(alias))
continue
}
if alias == docker.WildcardAlias {
wildcardProps = entryMap
continue
}
// check if it is an alias reference
switch alias[0] {
case aliasRefPrefix, aliasRefPrefixAlt:
index, err := strutils.Atoi(alias[1:])
if err != nil {
errs.Add(err)
break
}
if index < 1 || index > len(container.Aliases) {
errs.Add(ErrAliasRefIndexOutOfRange.Subject(strconv.Itoa(index)))
break
}
alias = container.Aliases[index-1]
}
// init entry if not exist
var en *entry.RawEntry
if en, ok = entries.Load(alias); !ok {
en = &entry.RawEntry{
Alias: alias,
Container: container,
}
entries.Store(alias, en)
}
// deserialize map into entry object
err := U.Deserialize(entryMap, en)
if err != nil {
errs.Add(err.Subject(alias))
} else {
entries.Store(alias, en)
}
}
if wildcardProps != nil {
entries.RangeAll(func(alias string, re *entry.RawEntry) {
if err := U.Deserialize(wildcardProps, re); err != nil {
errs.Add(err.Subject(alias))
}
}) })
}
return entries, errs.Error() return entries, errs.Error()
} }
func (p *DockerProvider) applyLabel(container *docker.Container, entries entry.RawEntries, key, val string) E.Error {
lbl := docker.ParseLabel(key, val)
if lbl.Namespace != docker.NSProxy {
return nil
}
if lbl.Target == docker.WildcardAlias {
// apply label for all aliases
labelErrs := entries.CollectErrors(func(a string, e *entry.RawEntry) error {
return docker.ApplyLabel(e, lbl)
})
if err := E.Join(labelErrs...); err != nil {
return err.Subject(lbl.Target)
}
return nil
}
refErrs := E.NewBuilder("alias ref errors")
replaceIndexRef := func(ref string) string {
index, err := strutils.Atoi(ref[1:])
if err != nil {
refErrs.Add(err)
return ref
}
if index < 1 || index > len(container.Aliases) {
refErrs.Add(ErrAliasRefIndexOutOfRange.Subject(strconv.Itoa(index)))
return ref
}
return container.Aliases[index-1]
}
lbl.Target = AliasRefRegex.ReplaceAllStringFunc(lbl.Target, replaceIndexRef)
lbl.Target = AliasRefRegexOld.ReplaceAllStringFunc(lbl.Target, func(ref string) string {
p.l.Warn().Msgf("%q should now be %q, old syntax will be removed in a future version", lbl, strings.ReplaceAll(lbl.String(), "$", "#"))
return replaceIndexRef(ref)
})
if refErrs.HasError() {
return refErrs.Error().Subject(lbl.String())
}
en, ok := entries.Load(lbl.Target)
if !ok {
en = &entry.RawEntry{
Alias: lbl.Target,
Container: container,
}
entries.Store(lbl.Target, en)
}
return docker.ApplyLabel(en, lbl)
}

View file

@ -31,7 +31,7 @@ func TestApplyLabel(t *testing.T) {
"POST /upload/{$}", "POST /upload/{$}",
"GET /static", "GET /static",
} }
middlewaresExpect := D.NestedLabelMap{ middlewaresExpect := map[string]map[string]any{
"middleware1": { "middleware1": {
"prop1": "value1", "prop1": "value1",
"prop2": "value2", "prop2": "value2",
@ -55,7 +55,6 @@ func TestApplyLabel(t *testing.T) {
"proxy.*.scheme": "https", "proxy.*.scheme": "https",
"proxy.*.host": "app", "proxy.*.host": "app",
"proxy.*.port": "4567", "proxy.*.port": "4567",
"proxy.a.no_tls_verify": "true",
"proxy.a.path_patterns": pathPatterns, "proxy.a.path_patterns": pathPatterns,
"proxy.a.middlewares.middleware1.prop1": "value1", "proxy.a.middlewares.middleware1.prop1": "value1",
"proxy.a.middlewares.middleware1.prop2": "value2", "proxy.a.middlewares.middleware1.prop2": "value2",
@ -215,6 +214,21 @@ func TestDynamicAliases(t *testing.T) {
ExpectEqual(t, raw.Port, "5678") ExpectEqual(t, raw.Port, "5678")
} }
func TestDisableHealthCheck(t *testing.T) {
c := D.FromDocker(&types.Container{
Names: dummyNames,
State: "running",
Labels: map[string]string{
"proxy.a.healthcheck.disable": "true",
"proxy.a.port": "1234",
},
}, client.DefaultDockerHost)
raw, ok := E.Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
ExpectEqual(t, raw.HealthCheck, nil)
}
func TestPublicIPLocalhost(t *testing.T) { func TestPublicIPLocalhost(t *testing.T) {
c := D.FromDocker(&types.Container{Names: dummyNames, State: "running"}, client.DefaultDockerHost) c := D.FromDocker(&types.Container{Names: dummyNames, State: "running"}, client.DefaultDockerHost)
raw, ok := E.Must(p.entriesFromContainerLabels(c)).Load("a") raw, ok := E.Must(p.entriesFromContainerLabels(c)).Load("a")

View file

@ -45,6 +45,7 @@ func (rt *Route) Container() *docker.Container {
} }
func NewRoute(raw *entry.RawEntry) (*Route, E.Error) { func NewRoute(raw *entry.RawEntry) (*Route, E.Error) {
raw.Finalize()
en, err := entry.ValidateEntry(raw) en, err := entry.ValidateEntry(raw)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -14,8 +14,8 @@ type HealthCheckConfig struct {
Timeout time.Duration `json:"timeout" yaml:"timeout"` Timeout time.Duration `json:"timeout" yaml:"timeout"`
} }
func DefaultHealthCheckConfig() HealthCheckConfig { func DefaultHealthCheckConfig() *HealthCheckConfig {
return HealthCheckConfig{ return &HealthCheckConfig{
Interval: common.HealthCheckIntervalDefault, Interval: common.HealthCheckIntervalDefault,
Timeout: common.HealthCheckTimeoutDefault, Timeout: common.HealthCheckTimeoutDefault,
} }