fix(docker): wildcard labels not applying properly for YAML style values and alias without labels

This commit is contained in:
yusing 2025-06-13 23:02:25 +08:00
parent 880d66c75e
commit f8c57d930f
4 changed files with 113 additions and 27 deletions

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/goccy/go-yaml"
"github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/utils/strutils"
) )
@ -12,11 +13,11 @@ type LabelMap = map[string]any
var ErrInvalidLabel = gperr.New("invalid label") var ErrInvalidLabel = gperr.New("invalid label")
func ParseLabels(labels map[string]string) (LabelMap, gperr.Error) { func ParseLabels(labels map[string]string, aliases ...string) (LabelMap, gperr.Error) {
nestedMap := make(LabelMap) nestedMap := make(LabelMap)
errs := gperr.NewBuilder("labels error") errs := gperr.NewBuilder("labels error")
ExpandWildcard(labels) ExpandWildcard(labels, aliases...)
for lbl, value := range labels { for lbl, value := range labels {
parts := strutils.SplitRune(lbl, '.') parts := strutils.SplitRune(lbl, '.')
@ -56,31 +57,92 @@ func ParseLabels(labels map[string]string) (LabelMap, gperr.Error) {
return nestedMap, errs.Error() return nestedMap, errs.Error()
} }
func ExpandWildcard(labels map[string]string) { func ExpandWildcard(labels map[string]string, aliases ...string) {
aliasLabels := make([]string, 0, len(labels)) // collect all explicit aliases first
aliasSet := make(map[string]struct{}, len(labels))
// wildcardLabels holds mapping suffix -> value derived from wildcard label definitions
wildcardLabels := make(map[string]string) wildcardLabels := make(map[string]string)
for lbl, value := range labels { for _, alias := range aliases {
parts := strings.SplitN(lbl, ".", 3) // Split into proxy, alias, rest aliasSet[alias] = struct{}{}
if parts[0] != NSProxy || len(parts) < 2 {
continue
}
alias := parts[1] // alias or wildcard alias
if alias == WildcardAlias {
delete(labels, lbl)
if len(parts) < 3 { // invalid wildcard label (no suffix)
continue
}
wildcardLabels[parts[2]] = value
} else {
// Extract just the alias part (first segment after proxy)
aliasLabels = append(aliasLabels, alias)
}
} }
for lbl, v := range wildcardLabels { // iterate over a copy of the keys to safely mutate the map while ranging
for _, alias := range aliasLabels { for lbl, value := range labels {
labels[fmt.Sprintf("%s.%s.%s", NSProxy, alias, lbl)] = v parts := strings.SplitN(lbl, ".", 3)
if len(parts) < 2 || parts[0] != NSProxy {
continue
}
alias := parts[1]
if alias == WildcardAlias { // "*"
// remove wildcard label from original map it should not remain afterwards
delete(labels, lbl)
// value looks like YAML (multiline)
if strings.Count(value, "\n") > 1 {
expandYamlWildcard(value, wildcardLabels)
continue
}
// normal wildcard label with suffix store directly
wildcardLabels[parts[2]] = value
continue
}
// explicit alias label remember the alias
aliasSet[alias] = struct{}{}
}
if len(aliasSet) == 0 || len(wildcardLabels) == 0 {
return // nothing to expand
}
// expand collected wildcard labels for every alias
for suffix, v := range wildcardLabels {
for alias := range aliasSet {
key := fmt.Sprintf("%s.%s.%s", NSProxy, alias, suffix)
if suffix == "" { // this should not happen (root wildcard handled earlier) but keep safe
key = fmt.Sprintf("%s.%s", NSProxy, alias)
}
labels[key] = v
}
}
}
// expandYamlWildcard parses a YAML document in value, flattens it to dot-notated keys and adds the
// results into dest map where each key is the flattened suffix and the value is the scalar string
// representation. The provided YAML is expected to be a mapping.
func expandYamlWildcard(value string, dest map[string]string) {
// replace tab indentation with spaces to make YAML parser happy
yamlStr := strings.ReplaceAll(value, "\t", " ")
raw := make(map[string]any)
if err := yaml.Unmarshal([]byte(yamlStr), &raw); err != nil {
// on parse error, ignore treat as no-op
return
}
flattenMap("", raw, dest)
}
// flattenMap converts nested maps into a flat map with dot-delimited keys.
func flattenMap(prefix string, src map[string]any, dest map[string]string) {
for k, v := range src {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch vv := v.(type) {
case map[string]any:
flattenMap(key, vv, dest)
case map[any]any:
// convert to map[string]any by stringifying keys
tmp := make(map[string]any, len(vv))
for kk, vvv := range vv {
tmp[fmt.Sprintf("%v", kk)] = vvv
}
flattenMap(key, tmp, dest)
default:
dest[key] = fmt.Sprint(v)
} }
} }
} }

View file

@ -28,6 +28,31 @@ func TestExpandWildcard(t *testing.T) {
}, labels) }, labels)
} }
func TestExpandWildcardYAML(t *testing.T) {
yaml := `
host: localhost
port: 5555
healthcheck:
disable: true`
labels := map[string]string{
"proxy.*": yaml[1:],
"proxy.a.port": "4444",
"proxy.a.healthcheck.disable": "false",
"proxy.a.healthcheck.path": "/health",
"proxy.b.port": "6666",
}
docker.ExpandWildcard(labels)
require.Equal(t, map[string]string{
"proxy.a.host": "localhost", // set by wildcard
"proxy.a.port": "5555", // overridden by wildcard
"proxy.a.healthcheck.disable": "true", // overridden by wildcard
"proxy.a.healthcheck.path": "/health", // own label
"proxy.b.host": "localhost", // set by wildcard
"proxy.b.port": "5555", // overridden by wildcard
"proxy.b.healthcheck.disable": "true", // overridden by wildcard
}, labels)
}
func BenchmarkParseLabels(b *testing.B) { func BenchmarkParseLabels(b *testing.B) {
for b.Loop() { for b.Loop() {
_, _ = docker.ParseLabels(map[string]string{ _, _ = docker.ParseLabels(map[string]string{

View file

@ -124,7 +124,7 @@ func (p *DockerProvider) routesFromContainerLabels(container *docker.Container)
errs := gperr.NewBuilder("label errors") errs := gperr.NewBuilder("label errors")
m, err := docker.ParseLabels(container.Labels) m, err := docker.ParseLabels(container.Labels, container.Aliases...)
errs.Add(err) errs.Add(err)
for alias, entryMapAny := range m { for alias, entryMapAny := range m {

View file

@ -4,7 +4,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client" "github.com/docker/docker/client"
D "github.com/yusing/go-proxy/internal/docker" D "github.com/yusing/go-proxy/internal/docker"
@ -319,7 +318,7 @@ func TestStreamDefaultValues(t *testing.T) {
}, },
}, },
}, },
Ports: []types.Port{ Ports: []container.Port{
{Type: "udp", PrivatePort: privPort, PublicPort: pubPort}, {Type: "udp", PrivatePort: privPort, PublicPort: pubPort},
}, },
} }
@ -372,7 +371,7 @@ func TestImplicitExcludeDatabase(t *testing.T) {
t.Run("exposed port detection", func(t *testing.T) { t.Run("exposed port detection", func(t *testing.T) {
r, ok := makeRoutes(&container.SummaryTrimmed{ r, ok := makeRoutes(&container.SummaryTrimmed{
Names: dummyNames, Names: dummyNames,
Ports: []types.Port{ Ports: []container.Port{
{Type: "tcp", PrivatePort: 5432, PublicPort: 5432}, {Type: "tcp", PrivatePort: 5432, PublicPort: 5432},
}, },
})["a"] })["a"]