Files
godoxy/internal/docker/label.go
Yuzerion 7b00a60f77 fix(docker): merge YAML objects into nested proxy labels (#219)
Sort proxy.* keys by dot depth, then name, before building the tree so
broader paths apply before deeper ones. When a new value would sit on a
node that is already a map, parse it as a YAML object (tabs normalized to
two spaces), deep-merge, and treat an empty string as an empty object.
Return clear errors when a scalar and a nested map disagree.

Drop the preallocated refPrefixes table in favor of refPrefix(n). Add
internal tests for parseLabelObject, mergeLabelMaps, key order, and
flatten; extend export tests for mixed OIDC-style labels and conflicts.

* refactor(docker): extract label parse and flatten helpers

Refactor ParseLabels by moving proxy label application into applyLabel,
descendLabelMap, and setLabelValue so traversal and leaf merge share one path
without labelLoop continues.

Add splitAliasLabel for ExpandWildcard so proxy.* prefix handling stays in one
place and uses CutPrefix/Cut consistently.

Deduplicate flattenMap and flattenMapAny value handling with flattenValue plus
joinLabelKey and stringifyLabelKey for flattened key construction.

* refactor(docker): structured errors for label type clashes

Replace ad hoc fmt.Errorf messages in descendLabelMap, setLabelValue, and
mergeLabelMaps with UnexpectedTypeError so wording is consistent and mapping
vs scalar conflicts stay explicit.

Hoist requireMap in label tests to a shared helper.

Normalize tabs to two spaces in expandYamlWildcard so wildcard YAML matches
the indentation used in the object-merge path.

* refactor(docker): optional UnexpectedTypeError message for merge conflicts

Extend UnexpectedTypeError with an optional Message field; when set, Error()
returns it instead of the default expect-versus-actual formatting.

mergeLabelMaps sets that message when a mapping would merge into an existing
scalar, so the error states the situation instead of only "expect scalar".

Update TestMergeLabelMaps to assert the new wording.
2026-04-13 15:21:42 +08:00

297 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package docker
import (
"cmp"
"errors"
"fmt"
"maps"
"slices"
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
)
var ErrInvalidLabel = errors.New("invalid label")
const nsProxyDot = NSProxy + "."
type UnexpectedTypeError struct {
Expected string
Actual any
// Message, if non-empty, is returned by Error() instead of the default "expect …, got …" form.
Message string
}
func (e UnexpectedTypeError) Error() string {
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("expect %s, got %T", e.Expected, e.Actual)
}
func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, error) {
nestedMap := make(types.LabelMap)
errs := gperr.NewBuilder("labels error")
ExpandWildcard(labels, aliases...)
keys := slices.SortedFunc(maps.Keys(labels), compareLabelKeys)
for _, lbl := range keys {
if err := applyLabel(nestedMap, lbl, labels[lbl]); err != nil {
errs.AddSubject(err, lbl)
}
}
return nestedMap, errs.Error()
}
func applyLabel(dst types.LabelMap, lbl, value string) error {
parts := strings.Split(lbl, ".")
if parts[0] != NSProxy {
return nil
}
if len(parts) == 1 {
return ErrInvalidLabel
}
currentMap := dst
for _, part := range parts[1 : len(parts)-1] {
nextMap, err := descendLabelMap(currentMap, part)
if err != nil {
return err
}
currentMap = nextMap
}
return setLabelValue(currentMap, parts[len(parts)-1], value)
}
func descendLabelMap(currentMap types.LabelMap, key string) (types.LabelMap, error) {
if next, ok := currentMap[key]; ok {
switch typed := next.(type) {
case types.LabelMap:
return typed, nil
case string:
objectValue, isObject := parseLabelObject(typed)
if !isObject {
return nil, UnexpectedTypeError{Expected: "mapping", Actual: next}
}
currentMap[key] = objectValue
return objectValue, nil
default:
return nil, UnexpectedTypeError{Expected: "mapping", Actual: next}
}
}
nextMap := make(types.LabelMap)
currentMap[key] = nextMap
return nextMap, nil
}
func setLabelValue(currentMap types.LabelMap, key, value string) error {
existing, ok := currentMap[key].(types.LabelMap)
if !ok {
currentMap[key] = value
return nil
}
objectValue, isObject := parseLabelObject(value)
if !isObject {
return UnexpectedTypeError{Expected: "mapping", Actual: value}
}
return mergeLabelMaps(existing, objectValue)
}
func parseLabelObject(value string) (types.LabelMap, bool) {
if value == "" {
return make(types.LabelMap), true
}
objectValue := make(types.LabelMap)
if err := yaml.Unmarshal([]byte(strings.ReplaceAll(value, "\t", " ")), &objectValue); err != nil {
return nil, false
}
return objectValue, true
}
func mergeLabelMaps(dst, src types.LabelMap) error {
for key, srcValue := range src {
existingValue, exists := dst[key]
if !exists {
dst[key] = srcValue
continue
}
existingMap, existingIsMap := existingValue.(types.LabelMap)
srcMap, srcIsMap := srcValue.(types.LabelMap)
if existingIsMap && srcIsMap {
if err := mergeLabelMaps(existingMap, srcMap); err != nil {
return err
}
continue
}
if existingIsMap {
return UnexpectedTypeError{Expected: "mapping", Actual: srcValue}
}
if srcIsMap {
return UnexpectedTypeError{
Expected: "scalar",
Actual: srcValue,
Message: fmt.Sprintf(
"cannot merge mapping into existing scalar; merge source is %T",
srcValue,
),
}
}
}
return nil
}
func compareLabelKeys(a, b string) int {
if parts := cmp.Compare(strings.Count(a, "."), strings.Count(b, ".")); parts != 0 {
return parts
}
return cmp.Compare(a, b)
}
func ExpandWildcard(labels map[string]string, aliases ...string) {
aliasSet := make(map[string]int, len(aliases))
for i, alias := range aliases {
aliasSet[alias] = i
}
wildcardLabels := make(map[string]string)
// First pass: collect wildcards and discover aliases
for lbl, value := range labels {
alias, suffix, ok := splitAliasLabel(lbl)
if !ok {
continue
}
if alias == WildcardAlias {
delete(labels, lbl)
if suffix == "" || strings.Count(value, "\n") > 1 {
expandYamlWildcard(value, wildcardLabels)
} else {
wildcardLabels[suffix] = value
}
continue
}
if suffix == "" || alias[0] == '#' {
continue
}
if _, known := aliasSet[alias]; !known {
aliasSet[alias] = len(aliasSet)
}
}
if len(aliasSet) == 0 || len(wildcardLabels) == 0 {
return
}
// Second pass: convert explicit labels to #N format
for lbl, value := range labels {
alias, suffix, ok := splitAliasLabel(lbl)
if !ok || suffix == "" || alias == "" || alias[0] == '#' {
continue
}
idx, known := aliasSet[alias]
if !known {
continue
}
delete(labels, lbl)
if _, overridden := wildcardLabels[suffix]; !overridden {
labels[refPrefix(idx)+suffix] = value
}
}
// Expand wildcards for all aliases
for suffix, value := range wildcardLabels {
for _, idx := range aliasSet {
labels[refPrefix(idx)+suffix] = value
}
}
}
func splitAliasLabel(lbl string) (alias, suffix string, ok bool) {
rest, ok := strings.CutPrefix(lbl, nsProxyDot)
if !ok {
return "", "", false
}
alias, suffix, _ = strings.Cut(rest, ".")
return alias, suffix, true
}
// 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)
}
// refPrefix returns the prefix for a reference to the Nth alias.
func refPrefix(n int) string {
return nsProxyDot + "#" + strconv.Itoa(n+1) + "."
}
// 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 {
flattenValue(joinLabelKey(prefix, k), v, dest)
}
}
func flattenMapAny(prefix string, src map[any]any, dest map[string]string) {
for k, v := range src {
flattenValue(joinLabelKey(prefix, stringifyLabelKey(k)), v, dest)
}
}
func flattenValue(key string, value any, dest map[string]string) {
switch typed := value.(type) {
case map[string]any:
flattenMap(key, typed, dest)
case map[any]any:
flattenMapAny(key, typed, dest)
case string:
dest[key] = typed
case int:
dest[key] = strconv.Itoa(typed)
case bool:
dest[key] = strconv.FormatBool(typed)
case float64:
dest[key] = strconv.FormatFloat(typed, 'f', -1, 64)
default:
dest[key] = fmt.Sprint(value)
}
}
func joinLabelKey(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}
func stringifyLabelKey(key any) string {
if typed, ok := key.(string); ok {
return typed
}
return fmt.Sprint(key)
}