From b38bff41d8af927a4152ec1d98be17885ce06538 Mon Sep 17 00:00:00 2001 From: yusing Date: Fri, 3 Jan 2025 15:35:40 +0800 Subject: [PATCH] support inline yaml for docker labels, serveral minor fixes --- internal/docker/label.go | 4 +- internal/route/provider/docker.go | 26 +++- internal/route/provider/docker_labels.yaml | 128 ++++++++++++++++++ internal/route/provider/docker_labels_test.go | 36 +++++ internal/route/provider/file_test.go | 4 +- internal/utils/serialization.go | 42 ++---- internal/utils/serialization_test.go | 50 +++++++ 7 files changed, 248 insertions(+), 42 deletions(-) create mode 100644 internal/route/provider/docker_labels.yaml create mode 100644 internal/route/provider/docker_labels_test.go diff --git a/internal/docker/label.go b/internal/docker/label.go index c176e2c..2dead0a 100644 --- a/internal/docker/label.go +++ b/internal/docker/label.go @@ -7,6 +7,8 @@ import ( type LabelMap = map[string]any +var ErrInvalidLabel = E.New("invalid label") + func ParseLabels(labels map[string]string) (LabelMap, E.Error) { nestedMap := make(LabelMap) errs := E.NewBuilder("labels error") @@ -17,7 +19,7 @@ func ParseLabels(labels map[string]string) (LabelMap, E.Error) { continue } if len(parts) == 1 { - errs.Add(E.Errorf("invalid label %s", lbl).Subject(lbl)) + errs.Add(ErrInvalidLabel.Subject(lbl)) continue } parts = parts[1:] diff --git a/internal/route/provider/docker.go b/internal/route/provider/docker.go index bbdf3da..4a42dbd 100755 --- a/internal/route/provider/docker.go +++ b/internal/route/provider/docker.go @@ -1,6 +1,7 @@ package provider import ( + "fmt" "strconv" "strings" @@ -13,6 +14,7 @@ import ( U "github.com/yusing/go-proxy/internal/utils" "github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/watcher" + "gopkg.in/yaml.v3" ) type DockerProvider struct { @@ -125,11 +127,19 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) continue } - var ok bool entryMap, ok := entryMapAny.(docker.LabelMap) if !ok { - errs.Add(E.Errorf("expect mapping, got %T", entryMap).Subject(alias)) - continue + // try to deserialize to map + entryMap = make(docker.LabelMap) + yamlStr, ok := entryMapAny.(string) + if !ok { + // should not happen + panic(fmt.Errorf("invalid entry map type %T", entryMapAny)) + } + if err := yaml.Unmarshal([]byte(yamlStr), &entryMap); err != nil { + errs.Add(E.From(err).Subject(alias)) + continue + } } if alias == docker.WildcardAlias { @@ -153,8 +163,8 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) } // init entry if not exist - var en *route.RawEntry - if en, ok = entries.Load(alias); !ok { + en, ok := entries.Load(alias) + if !ok { en = &route.RawEntry{ Alias: alias, Container: container, @@ -171,10 +181,12 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) } } if wildcardProps != nil { - entries.RangeAll(func(alias string, re *route.RawEntry) { + entries.Range(func(alias string, re *route.RawEntry) bool { if err := U.Deserialize(wildcardProps, re); err != nil { - errs.Add(err.Subject(alias)) + errs.Add(err.Subject(docker.WildcardAlias)) + return false } + return true }) } diff --git a/internal/route/provider/docker_labels.yaml b/internal/route/provider/docker_labels.yaml new file mode 100644 index 0000000..8065654 --- /dev/null +++ b/internal/route/provider/docker_labels.yaml @@ -0,0 +1,128 @@ +proxy.aliases: app,app1 +# style 1 - inline yaml +proxy.app: | + scheme: http + host: 10.0.0.254 + port: 80 + path_patterns: # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax + - GET / # accept any GET request + - POST /auth # for /auth and /auth/* accept only POST + - GET /home/{$} # for exactly /home + healthcheck: + disabled: false + path: / + interval: 5s + load_balance: + link: app + mode: ip_hash + options: + header: X-Forwarded-For + middlewares: + cidr_whitelist: + allow: + - 127.0.0.1 + - 10.0.0.0/8 + status_code: 403 + message: IP not allowed + hideXForwarded: + homepage: + name: Example App + icon: png/example.png + description: An example app + category: example + access_log: + buffer_size: 100 + path: /var/log/example.log + filters: + status_codes: + values: + - 200-299 + - 101 + method: + values: + - GET + host: + values: + - example.y.z + headers: + negative: true + values: + - foo=bar + - baz + cidr: + values: + - 192.168.10.0/24 + fields: + headers: + default: keep + config: + foo: redact + query: + default: drop + config: + foo: keep + cookies: + default: redact + config: + foo: keep + +# style 2 - full labels and mixed +proxy.app1.scheme: http +proxy.app1.host: 10.0.0.254 +proxy.app1.port: 80 +proxy.app1.path_patterns: + | # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax + GET / # accept any GET request + POST /auth # for /auth and /auth/* accept only POST + GET /home/{$} # for exactly /home +proxy.app1.healthcheck.disabled: false +proxy.app1.healthcheck.path: / +proxy.app1.healthcheck.interval: 5s +proxy.app1.load_balance.link: app +proxy.app1.load_balance.mode: ip_hash +proxy.app1.load_balance.options.header: X-Forwarded-For +proxy.app1.middlewares.cidr_whitelist: | + allow: + - 127.0.0.1 + - 10.0.0.0/8 + status_code: 403 + message: IP not allowed +proxy.app1.middlewares.hideXForwarded: +proxy.app1.homepage.name: Example App +proxy.app1.homepage.icon: png/example.png +proxy.app1.homepage.description: An example app +proxy.app1.homepage.category: example +proxy.app1.access_log.buffer_size: 100 +proxy.app1.access_log.path: /var/log/example.log +proxy.app1.access_log.filters: | + status_codes: + values: + - 200-299 + - 101 + method: + values: + - GET + host: + values: + - example.y.z + headers: + negative: true + values: + - foo=bar + - baz + cidr: + values: + - 192.168.10.0/24 +proxy.app1.access_log.fields: | + headers: + default: keep + config: + foo: redact + query: + default: drop + config: + foo: keep + cookies: + default: redact + config: + foo: keep diff --git a/internal/route/provider/docker_labels_test.go b/internal/route/provider/docker_labels_test.go new file mode 100644 index 0000000..7c9b0cb --- /dev/null +++ b/internal/route/provider/docker_labels_test.go @@ -0,0 +1,36 @@ +package provider + +import ( + "testing" + + "github.com/docker/docker/api/types" + "github.com/yusing/go-proxy/internal/docker" + . "github.com/yusing/go-proxy/internal/utils/testing" + "gopkg.in/yaml.v3" + + _ "embed" +) + +//go:embed docker_labels.yaml +var testDockerLabelsYAML []byte + +func TestParseDockerLabels(t *testing.T) { + var provider DockerProvider + + labels := make(map[string]string) + ExpectNoError(t, yaml.Unmarshal(testDockerLabelsYAML, &labels)) + + routes, err := provider.entriesFromContainerLabels( + docker.FromDocker(&types.Container{ + Names: []string{"container"}, + Labels: labels, + State: "running", + Ports: []types.Port{ + {Type: "tcp", PrivatePort: 1234, PublicPort: 1234}, + }, + }, "/var/run/docker.sock"), + ) + ExpectNoError(t, err) + ExpectTrue(t, routes.Has("app")) + ExpectTrue(t, routes.Has("app1")) +} diff --git a/internal/route/provider/file_test.go b/internal/route/provider/file_test.go index afe2782..756095c 100644 --- a/internal/route/provider/file_test.go +++ b/internal/route/provider/file_test.go @@ -9,9 +9,9 @@ import ( ) //go:embed all_fields.yaml -var yaml []byte +var testAllFieldsYAML []byte func TestFile(t *testing.T) { - _, err := validate(yaml) + _, err := validate(testAllFieldsYAML) ExpectNoError(t, err) } diff --git a/internal/utils/serialization.go b/internal/utils/serialization.go index 8b7c7c5..4d974d0 100644 --- a/internal/utils/serialization.go +++ b/internal/utils/serialization.go @@ -24,8 +24,6 @@ var ( ErrNilValue = E.New("nil") ErrUnsettable = E.New("unsettable") ErrUnsupportedConversion = E.New("unsupported conversion") - ErrMapMissingColon = E.New("map missing colon") - ErrMapTooManyColons = E.New("map too many colons") ErrUnknownField = E.New("unknown field") ) @@ -422,19 +420,12 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr E.E return true, E.From(parser.Parse(src)) } // yaml like - lines := []string{} - src = strings.TrimSpace(src) - if src != "" { - lines = strutils.SplitLine(src) - for i := range lines { - lines[i] = strings.TrimSpace(lines[i]) - } - } + isMultiline := strings.ContainsRune(src, '\n') var tmp any switch dst.Kind() { case reflect.Slice: // one liner is comma separated list - if len(lines) == 1 { + if !isMultiline { values := strutils.CommaSeperatedList(src) dst.Set(reflect.MakeSlice(dst.Type(), len(values), len(values))) errs := E.NewBuilder("invalid slice values") @@ -449,38 +440,25 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr E.E } return } + lines := strutils.SplitLine(src) sl := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimLeftFunc(line, func(r rune) bool { return r == '-' || unicode.IsSpace(r) }) - if line == "" { + if line == "" || line[0] == '#' { continue } sl = append(sl, line) } tmp = sl - case reflect.Map: - m := make(map[string]string, len(lines)) - errs := E.NewBuilder("invalid map") - for i, line := range lines { - parts := strings.Split(line, ":") - if len(parts) < 2 { - errs.Add(ErrMapMissingColon.Subjectf("line %d", i+1)) - continue - } - if len(parts) > 2 { - errs.Add(ErrMapTooManyColons.Subjectf("line %d", i+1)) - continue - } - k := strings.TrimSpace(parts[0]) - v := strings.TrimSpace(parts[1]) - m[k] = v + case reflect.Map, reflect.Struct: + rawMap := make(SerializedObject) + err := yaml.Unmarshal([]byte(src), &rawMap) + if err != nil { + return true, E.From(err) } - if errs.HasError() { - return true, errs.Error() - } - tmp = m + tmp = rawMap default: return false, nil } diff --git a/internal/utils/serialization_test.go b/internal/utils/serialization_test.go index 45ba66e..abca97c 100644 --- a/internal/utils/serialization_test.go +++ b/internal/utils/serialization_test.go @@ -163,3 +163,53 @@ func TestConvertor(t *testing.T) { ExpectError(t, ErrUnsupportedConversion, Deserialize(map[string]any{"Test": struct{}{}}, m)) }) } + +func TestStringToSlice(t *testing.T) { + t.Run("comma_separated", func(t *testing.T) { + dst := make([]string, 0) + convertible, err := ConvertString("a,b,c", reflect.ValueOf(&dst)) + ExpectTrue(t, convertible) + ExpectNoError(t, err) + ExpectDeepEqual(t, dst, []string{"a", "b", "c"}) + }) + t.Run("multiline", func(t *testing.T) { + dst := make([]string, 0) + convertible, err := ConvertString(" a\n b\n c", reflect.ValueOf(&dst)) + ExpectTrue(t, convertible) + ExpectNoError(t, err) + ExpectDeepEqual(t, dst, []string{"a", "b", "c"}) + }) + t.Run("yaml-like", func(t *testing.T) { + dst := make([]string, 0) + convertible, err := ConvertString(" - a\n - b\n - c", reflect.ValueOf(&dst)) + ExpectTrue(t, convertible) + ExpectNoError(t, err) + ExpectDeepEqual(t, dst, []string{"a", "b", "c"}) + }) +} + +func TestStringToMap(t *testing.T) { + t.Run("yaml-like", func(t *testing.T) { + dst := make(map[string]string) + convertible, err := ConvertString(" a: b\n c: d", reflect.ValueOf(&dst)) + ExpectTrue(t, convertible) + ExpectNoError(t, err) + ExpectDeepEqual(t, dst, map[string]string{"a": "b", "c": "d"}) + }) +} + +func TestStringToStruct(t *testing.T) { + t.Run("yaml-like", func(t *testing.T) { + dst := struct { + A string + B int + }{} + convertible, err := ConvertString(" A: a\n B: 123", reflect.ValueOf(&dst)) + ExpectTrue(t, convertible) + ExpectNoError(t, err) + ExpectDeepEqual(t, dst, struct { + A string + B int + }{"a", 123}) + }) +}