diff --git a/internal/docker/label.go b/internal/docker/label.go index 0d13cd5..188d6a6 100644 --- a/internal/docker/label.go +++ b/internal/docker/label.go @@ -1,125 +1,51 @@ package docker import ( - "reflect" "strings" E "github.com/yusing/go-proxy/internal/error" - U "github.com/yusing/go-proxy/internal/utils" ) -/* -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 -) +type LabelMap = map[string]any -var ( - ErrApplyToNil = E.New("label value is nil") - ErrFieldNotExist = E.New("field does not exist") -) +func ParseLabels(labels map[string]string) (LabelMap, E.Error) { + nestedMap := make(LabelMap) + errs := E.NewBuilder("labels error") -func (l *Label) String() string { - if l.Attribute == "" { - return l.Namespace + "." + l.Target - } - return l.Namespace + "." + l.Target + "." + l.Attribute -} - -// Apply applies the value of a Label to the corresponding field in the given object. -// -// Parameters: -// - obj: a pointer to the object to which the Label value will be applied. -// - 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 - } + for lbl, value := range labels { + parts := strings.Split(lbl, ".") + if parts[0] != NSProxy { + continue } - if !field.IsValid() { - return ErrFieldNotExist.Subject(l.Attribute).Subject(l.String()) + if len(parts) == 1 { + errs.Add(E.Errorf("invalid label %s", lbl).Subject(lbl)) + continue } - dst, ok := field.Interface().(NestedLabelMap) - if !ok { - if field.Kind() == reflect.Ptr { - if field.IsNil() { - field.Set(reflect.New(field.Type().Elem())) - } + parts = parts[1:] + currentMap := nestedMap + + for i, k := range parts { + if i == len(parts)-1 { + // Last element, set the value + currentMap[k] = value } else { - field = field.Addr() + // If the key doesn't exist, create a new map + if _, exists := currentMap[k]; !exists { + currentMap[k] = make(LabelMap) + } + // Move deeper into the nested map + m, ok := currentMap[k].(LabelMap) + 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 + } + currentMap = m } - err := U.Deserialize(U.SerializedObject{nestedLabel.Namespace: nestedLabel.Value}, field.Interface()) - if err != nil { - return err.Subject(l.String()) - } - return nil } - 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 { - 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 + + return nestedMap, errs.Error() } diff --git a/internal/docker/label_test.go b/internal/docker/label_test.go deleted file mode 100644 index 4591774..0000000 --- a/internal/docker/label_test.go +++ /dev/null @@ -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) -} diff --git a/internal/docker/labels.go b/internal/docker/labels.go index 10f0c9e..8c78e79 100644 --- a/internal/docker/labels.go +++ b/internal/docker/labels.go @@ -3,8 +3,7 @@ package docker const ( WildcardAlias = "*" - NSProxy = "proxy" - NSHomePage = "homepage" + NSProxy = "proxy" LabelAliases = NSProxy + ".aliases" LabelExclude = NSProxy + ".exclude" diff --git a/internal/proxy/entry/entry.go b/internal/proxy/entry/entry.go index 4eb71f3..66a237e 100644 --- a/internal/proxy/entry/entry.go +++ b/internal/proxy/entry/entry.go @@ -19,8 +19,6 @@ type Entry interface { } func ValidateEntry(m *RawEntry) (Entry, E.Error) { - m.FillMissingFields() - scheme, err := T.NewScheme(m.Scheme) if err != nil { return nil, E.From(err) @@ -36,6 +34,9 @@ func ValidateEntry(m *RawEntry) (Entry, E.Error) { if errs.HasError() { 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 } diff --git a/internal/proxy/entry/raw.go b/internal/proxy/entry/raw.go index 1ebe78d..0f0836e 100644 --- a/internal/proxy/entry/raw.go +++ b/internal/proxy/entry/raw.go @@ -22,19 +22,21 @@ type ( // raw entry object before validation // loaded from docker labels or yaml file - Alias string `json:"-" yaml:"-"` - Scheme string `json:"scheme,omitempty" yaml:"scheme"` - Host string `json:"host,omitempty" yaml:"host"` - Port string `json:"port,omitempty" yaml:"port"` - NoTLSVerify bool `json:"no_tls_verify,omitempty" yaml:"no_tls_verify"` // https proxy only - PathPatterns []string `json:"path_patterns,omitempty" yaml:"path_patterns"` // http(s) proxy only - HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck"` - LoadBalance *loadbalancer.Config `json:"load_balance,omitempty" yaml:"load_balance"` - Middlewares docker.NestedLabelMap `json:"middlewares,omitempty" yaml:"middlewares"` - Homepage *homepage.Item `json:"homepage,omitempty" yaml:"homepage"` + Alias string `json:"-" yaml:"-"` + Scheme string `json:"scheme,omitempty" yaml:"scheme"` + Host string `json:"host,omitempty" yaml:"host"` + Port string `json:"port,omitempty" yaml:"port"` + NoTLSVerify bool `json:"no_tls_verify,omitempty" yaml:"no_tls_verify"` // https proxy only + PathPatterns []string `json:"path_patterns,omitempty" yaml:"path_patterns"` // http(s) proxy only + HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck"` + LoadBalance *loadbalancer.Config `json:"load_balance,omitempty" yaml:"load_balance"` + Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty" yaml:"middlewares"` + Homepage *homepage.Item `json:"homepage,omitempty" yaml:"homepage"` /* Docker only */ Container *docker.Container `json:"container,omitempty" yaml:"-"` + + finalized bool } RawEntries = F.Map[string, *RawEntry] @@ -42,7 +44,11 @@ type ( var NewProxyEntries = F.NewMapOf[string, *RawEntry] -func (e *RawEntry) FillMissingFields() { +func (e *RawEntry) Finalize() { + if e.finalized { + return + } + isDocker := e.Container != nil cont := e.Container if !isDocker { @@ -124,14 +130,7 @@ func (e *RawEntry) FillMissingFields() { } if e.HealthCheck == nil { - e.HealthCheck = new(health.HealthCheckConfig) - } - - if e.HealthCheck.Interval == 0 { - e.HealthCheck.Interval = common.HealthCheckIntervalDefault - } - if e.HealthCheck.Timeout == 0 { - e.HealthCheck.Timeout = common.HealthCheckTimeoutDefault + e.HealthCheck = health.DefaultHealthCheckConfig() } if e.HealthCheck.Disable { @@ -159,6 +158,8 @@ func (e *RawEntry) FillMissingFields() { e.Port = "0" } } + + e.finalized = true } func (e *RawEntry) splitPorts() (lp string, pp string, extra string) { @@ -168,9 +169,9 @@ func (e *RawEntry) splitPorts() (lp string, pp string, extra string) { } else { lp = portSplit[0] pp = portSplit[1] - } - if len(portSplit) > 2 { - extra = strings.Join(portSplit[2:], ":") + if len(portSplit) > 2 { + extra = strings.Join(portSplit[2:], ":") + } } return } diff --git a/internal/proxy/entry/reverse_proxy.go b/internal/proxy/entry/reverse_proxy.go index fc9d5f4..e16278e 100644 --- a/internal/proxy/entry/reverse_proxy.go +++ b/internal/proxy/entry/reverse_proxy.go @@ -16,14 +16,14 @@ import ( type ReverseProxyEntry struct { // real model after validation Raw *RawEntry `json:"raw"` - Alias fields.Alias `json:"alias"` - Scheme fields.Scheme `json:"scheme"` - URL net.URL `json:"url"` - NoTLSVerify bool `json:"no_tls_verify,omitempty"` - PathPatterns fields.PathPatterns `json:"path_patterns,omitempty"` - HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` - LoadBalance *loadbalancer.Config `json:"load_balance,omitempty"` - Middlewares docker.NestedLabelMap `json:"middlewares,omitempty"` + Alias fields.Alias `json:"alias"` + Scheme fields.Scheme `json:"scheme"` + URL net.URL `json:"url"` + NoTLSVerify bool `json:"no_tls_verify,omitempty"` + PathPatterns fields.PathPatterns `json:"path_patterns,omitempty"` + HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` + LoadBalance *loadbalancer.Config `json:"load_balance,omitempty"` + Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"` /* Docker only */ Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` diff --git a/internal/proxy/fields/path_pattern.go b/internal/proxy/fields/path_pattern.go index 29b7cf4..0d6df1c 100644 --- a/internal/proxy/fields/path_pattern.go +++ b/internal/proxy/fields/path_pattern.go @@ -32,7 +32,7 @@ func ValidatePathPattern(s string) (PathPattern, error) { func ValidatePathPatterns(s []string) (PathPatterns, E.Error) { if len(s) == 0 { - return []PathPattern{"/"}, nil + return nil, nil } errs := E.NewBuilder("invalid path patterns") pp := make(PathPatterns, len(s)) diff --git a/internal/route/provider/docker.go b/internal/route/provider/docker.go index 0cb5ea8..94e0426 100755 --- a/internal/route/provider/docker.go +++ b/internal/route/provider/docker.go @@ -1,7 +1,6 @@ package provider import ( - "regexp" "strconv" "strings" @@ -12,6 +11,7 @@ import ( E "github.com/yusing/go-proxy/internal/error" "github.com/yusing/go-proxy/internal/proxy/entry" "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/watcher" ) @@ -22,13 +22,13 @@ type DockerProvider struct { l zerolog.Logger } -var ( - AliasRefRegex = regexp.MustCompile(`#\d+`) - AliasRefRegexOld = regexp.MustCompile(`\$\d+`) - - ErrAliasRefIndexOutOfRange = E.New("index out of range") +const ( + aliasRefPrefix = '#' + aliasRefPrefixAlt = '$' ) +var ErrAliasRefIndexOutOfRange = E.New("index out of range") + func DockerProviderImpl(name, dockerHost string, explicitOnly bool) (ProviderImpl, error) { if dockerHost == common.DockerHostFromEnv { dockerHost = common.GetEnvString("DOCKER_HOST", client.DefaultDockerHost) @@ -114,65 +114,70 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) } errs := E.NewBuilder("label errors") - for key, val := range container.Labels { - errs.Add(p.applyLabel(container, entries, key, val)) - } - // remove all entries that failed to fill in missing fields - entries.RangeAll(func(_ string, re *entry.RawEntry) { - re.FillMissingFields() - }) + 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 + } + + var ok bool + entryMap, ok := entryMapAny.(docker.LabelMap) + 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() } - -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) -} diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index dfd0558..a5147c8 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -31,7 +31,7 @@ func TestApplyLabel(t *testing.T) { "POST /upload/{$}", "GET /static", } - middlewaresExpect := D.NestedLabelMap{ + middlewaresExpect := map[string]map[string]any{ "middleware1": { "prop1": "value1", "prop2": "value2", @@ -55,7 +55,6 @@ func TestApplyLabel(t *testing.T) { "proxy.*.scheme": "https", "proxy.*.host": "app", "proxy.*.port": "4567", - "proxy.a.no_tls_verify": "true", "proxy.a.path_patterns": pathPatterns, "proxy.a.middlewares.middleware1.prop1": "value1", "proxy.a.middlewares.middleware1.prop2": "value2", @@ -215,6 +214,21 @@ func TestDynamicAliases(t *testing.T) { 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) { c := D.FromDocker(&types.Container{Names: dummyNames, State: "running"}, client.DefaultDockerHost) raw, ok := E.Must(p.entriesFromContainerLabels(c)).Load("a") diff --git a/internal/route/route.go b/internal/route/route.go index fe0f800..d71962f 100755 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -45,6 +45,7 @@ func (rt *Route) Container() *docker.Container { } func NewRoute(raw *entry.RawEntry) (*Route, E.Error) { + raw.Finalize() en, err := entry.ValidateEntry(raw) if err != nil { return nil, err diff --git a/internal/watcher/health/healthcheck_config.go b/internal/watcher/health/healthcheck_config.go index 2857142..4d1f830 100644 --- a/internal/watcher/health/healthcheck_config.go +++ b/internal/watcher/health/healthcheck_config.go @@ -14,8 +14,8 @@ type HealthCheckConfig struct { Timeout time.Duration `json:"timeout" yaml:"timeout"` } -func DefaultHealthCheckConfig() HealthCheckConfig { - return HealthCheckConfig{ +func DefaultHealthCheckConfig() *HealthCheckConfig { + return &HealthCheckConfig{ Interval: common.HealthCheckIntervalDefault, Timeout: common.HealthCheckTimeoutDefault, }