refactor(hash): review nits — shared helpers, hardened reflection walker

The trimming of hash-excluded fields becomes trimServiceHashFields,
shared between ServiceHash and the continuity test so the exclusion set
cannot silently drift; pinRootKeyOrder reuses the package's sortedKeys
helper; the key-order coverage walker now follows encoding/json
promotion rules for non-struct embeds and unexported fields.
Golden hashes unchanged.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-22 09:49:51 +02:00
committed by Guillaume Lours
parent 725fe535c0
commit 0c68d08f26
2 changed files with 24 additions and 24 deletions
+12 -10
View File
@@ -19,7 +19,6 @@ package compose
import (
"bytes"
"encoding/json"
"sort"
"github.com/compose-spec/compose-go/v2/types"
"github.com/opencontainers/go-digest"
@@ -144,9 +143,11 @@ var serviceHashKeyOrder = []string{
"pre_stop",
}
// ServiceHash computes the configuration hash for a service.
func ServiceHash(o types.ServiceConfig) (string, error) {
// remove the Build config when generating the service hash
// trimServiceHashFields removes the attributes deliberately excluded from the
// service config-hash (build inputs, scaling knobs, dependency wiring). It is
// the single definition of that exclusion set, shared with the continuity
// test so the two cannot silently drift apart.
func trimServiceHashFields(o types.ServiceConfig) types.ServiceConfig {
o.Build = nil
o.PullPolicy = ""
o.Scale = nil
@@ -157,6 +158,12 @@ func ServiceHash(o types.ServiceConfig) (string, error) {
}
o.DependsOn = nil
o.Profiles = nil
return o
}
// ServiceHash computes the configuration hash for a service.
func ServiceHash(o types.ServiceConfig) (string, error) {
o = trimServiceHashFields(o)
raw, err := json.Marshal(o)
if err != nil {
@@ -197,12 +204,7 @@ func pinRootKeyOrder(raw []byte, order []string) ([]byte, error) {
delete(root, key)
}
}
rest := make([]string, 0, len(root))
for key := range root {
rest = append(rest, key)
}
sort.Strings(rest)
for _, key := range rest {
for _, key := range sortedKeys(root) {
write(key, root[key])
}
buf.WriteByte('}')
+12 -14
View File
@@ -57,18 +57,7 @@ func TestServiceHashContinuity(t *testing.T) {
pinned, err := ServiceHash(svc)
assert.NilError(t, err)
o := svc
o.Build = nil
o.PullPolicy = ""
o.Scale = nil
if o.Deploy != nil {
deploy := *o.Deploy
deploy.Replicas = nil
o.Deploy = &deploy
}
o.DependsOn = nil
o.Profiles = nil
raw, err := json.Marshal(o)
raw, err := json.Marshal(trimServiceHashFields(svc))
assert.NilError(t, err)
legacy := digest.SHA256.FromBytes(raw).Encoded()
assert.Equal(t, pinned, legacy)
@@ -180,12 +169,21 @@ func TestServiceHashKeyOrderCoversStruct(t *testing.T) {
continue
}
if f.Anonymous && name == "" {
// encoding/json dereferences embedded pointers; match it
// encoding/json dereferences embedded pointers and promotes
// the exported fields of even an unexported struct embed;
// match it, and leave non-struct embeds (named types,
// interfaces) to the regular field handling below
ft := f.Type
if ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
walk(ft)
if ft.Kind() == reflect.Struct {
walk(ft)
continue
}
}
if !f.IsExported() {
// unexported fields never reach the JSON output
continue
}
if name == "" {