This is an automated email from the ASF dual-hosted git repository.
wilfred-s pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-core.git
The following commit(s) were added to refs/heads/master by this push:
new 5dae0cbc [YUNIKORN-3347] Improve checksum generation and checks (#1126)
5dae0cbc is described below
commit 5dae0cbc70a38dddb827906b6e8fd6a296ce69eb
Author: sidbroski <[email protected]>
AuthorDate: Thu Aug 20 19:24:29 2026 +1000
[YUNIKORN-3347] Improve checksum generation and checks (#1126)
Improved checksum generation and checks, implemented efficient detection
of the config checksum at the start or end of the serialised config,
override missing/incorrect checksum in memory with logging,
and return the checksum plus comparison result from validateConf.
Closes: #1126
Signed-off-by: Wilfred Spiegelenburg <[email protected]>
---
pkg/common/configs/config.go | 77 ++++++++++++++++++++++++++++++++-------
pkg/common/configs/config_test.go | 50 +++++++++++++++++++++++--
pkg/scheduler/context.go | 7 +++-
pkg/webservice/dao/config_info.go | 6 ++-
pkg/webservice/handlers.go | 16 ++++++--
pkg/webservice/handlers_test.go | 33 +++++++++++++++++
6 files changed, 165 insertions(+), 24 deletions(-)
diff --git a/pkg/common/configs/config.go b/pkg/common/configs/config.go
index 50a35fe6..ad0097a8 100644
--- a/pkg/common/configs/config.go
+++ b/pkg/common/configs/config.go
@@ -24,7 +24,6 @@ import (
"errors"
"fmt"
"io"
- "strings"
"go.uber.org/zap"
"go.yaml.in/yaml/v3"
@@ -32,6 +31,19 @@ import (
"github.com/apache/yunikorn-core/pkg/log"
)
+const (
+ // checksumKey is the YAML key that holds the checksum in a serialised
scheduler config.
+ checksumKey = "checksum:"
+ // partitionsKey is the first YAML key of a serialised scheduler
config. It must always be present
+ // and is used to locate the start of the config when the checksum is
stored before it.
+ partitionsKey = "partitions:"
+ // checksumScanWindow is the number of bytes scanned at the start and
the end of a serialised config
+ // to locate the checksum line. Any checksum line would never exceed
this size ("checksum: " plus a 64 character
+ // hex string with an optional trailing newline and quotes).
+ // Scanning only the head and the tail keeps the detection cheap on
large configs which could be megabytes in size.
+ checksumScanWindow = 80
+)
+
// SchedulerConfig can contain multiple partitions. Each partition contains
the queue definition for a logical
// set of scheduler resources.
type SchedulerConfig struct {
@@ -168,9 +180,25 @@ func LoadSchedulerConfigFromByteArray(content []byte)
(*SchedulerConfig, error)
return conf, err
}
+// SetChecksum calculates the sha256 checksum for the serialised config and
stores it in the config.
+// The config might already contain a checksum read from the YAML, it could be
missing or set to an incorrect value.
+// The correct checksum will always override it, the action taken is logged so
that an incorrect checksum can be traced.
func SetChecksum(content []byte, conf *SchedulerConfig) {
- noChecksumContent := GetConfigurationString(content)
- conf.Checksum = fmt.Sprintf("%X",
sha256.Sum256([]byte(noChecksumContent)))
+ // nil safety
+ if conf == nil {
+ return
+ }
+ checksum := fmt.Sprintf("%X",
sha256.Sum256([]byte(GetConfigurationString(content))))
+ old := conf.Checksum
+ conf.Checksum = checksum
+ switch {
+ case old == "":
+ log.Log(log.Config).Debug("checksum not set in configuration,
calculated and stored",
+ zap.String("checksum", checksum))
+ case old != checksum:
+ log.Log(log.Config).Warn("checksum in configuration incorrect,
overriding with calculated value",
+ zap.String("oldChecksum", old),
zap.String("newChecksum", checksum))
+ }
}
func ParseAndValidateConfig(content []byte) (*SchedulerConfig, error) {
@@ -193,18 +221,41 @@ func ParseAndValidateConfig(content []byte)
(*SchedulerConfig, error) {
return conf, nil
}
+// GetConfigurationString returns the serialised config content without
checksum.
+// The checksum is placed at the start or the end of the config and to avoid
walking a potentially very large
+// config end to end, only the first and the last checksumScanWindow bytes are
scanned for the checksum key.
func GetConfigurationString(requestBytes []byte) string {
- conf := string(requestBytes)
- checksum := "checksum: "
- checksumLength := 64 + len(checksum)
- if strings.Contains(conf, checksum) {
- checksum += strings.Split(conf, checksum)[1]
- checksum = strings.TrimRight(checksum, "\n")
- if len(checksum) > checksumLength {
- checksum = checksum[:checksumLength]
- }
+ length := len(requestBytes)
+ if length == 0 {
+ return ""
+ }
+ key := []byte(checksumKey)
+ // look for the checksum in the tail of the config first, then in the
head, as the Checksum is the last field of the
+ // SchedulerConfig so standard yaml serialisation places it in the tail
of the config
+ tail := max(length-checksumScanWindow, 0)
+ checksumIdx := bytes.Index(requestBytes[tail:], key)
+ if checksumIdx != -1 {
+ checksumIdx += tail
+ } else {
+ head := min(checksumScanWindow, length)
+ checksumIdx = bytes.Index(requestBytes[:head], key)
+ }
+ // no checksum found: the whole content is used to calculate the
checksum
+ if checksumIdx == -1 {
+ return string(requestBytes)
+ }
+ // a checksum is present: use the partitions key to decide whether it
sits before or after the config
+ partitionsIdx := bytes.Index(requestBytes, []byte(partitionsKey))
+ if partitionsIdx == -1 {
+ // no partitions in the config: nothing to calculate a checksum
over
+ return ""
+ }
+ if checksumIdx < partitionsIdx {
+ // checksum stored before the config: the config runs from the
partitions key to the end
+ return string(requestBytes[partitionsIdx:])
}
- return strings.ReplaceAll(conf, checksum, "")
+ // checksum stored after the config: the config runs up to the checksum
line
+ return string(requestBytes[:checksumIdx])
}
// DefaultSchedulerConfig contains the default scheduler configuration; used
if no other is provided
diff --git a/pkg/common/configs/config_test.go
b/pkg/common/configs/config_test.go
index 72eaf31e..0e2b48c3 100644
--- a/pkg/common/configs/config_test.go
+++ b/pkg/common/configs/config_test.go
@@ -1750,18 +1750,21 @@ partitions:
func TestGetConfigurationString(t *testing.T) {
configBytes := []byte(validConf)
checksum := "checksum: " + fmt.Sprintf("%X", sha256.Sum256(configBytes))
+ // the checksum is calculated on the partitions key so the leading
newline should not be part of it
+ confFromPartitions := strings.TrimPrefix(validConf, "\n")
testCases := []struct {
name string
requestBytes []byte
expectedConfig string
}{
{"No checksum", configBytes, validConf},
- {"Checksum at the beginning", []byte(checksum + validConf),
validConf},
+ {"Checksum at the beginning", []byte(checksum + validConf),
confFromPartitions},
+ {"Checksum at the beginning with newline", []byte(checksum +
"\n" + validConf), confFromPartitions},
{"Checksum at the end", []byte(validConf + checksum),
validConf},
- {"Checksum in the middle", []byte(validConf + checksum + "extra
config"), validConf + "extra config"},
+ {"Checksum at the end with newline", []byte(validConf +
checksum + "\n"), validConf},
{"Empty config and checksum", []byte(""), ""},
{"Empty checksum", []byte(validConf + "checksum: "), validConf},
- {"Empty config", []byte("" + checksum), ""},
+ {"Only checksum no partitions", []byte(checksum), ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
@@ -1770,6 +1773,47 @@ func TestGetConfigurationString(t *testing.T) {
}
}
+func TestSetChecksum(t *testing.T) {
+ content := []byte(validConf)
+ expected := fmt.Sprintf("%X",
sha256.Sum256([]byte(GetConfigurationString(content))))
+ testCases := []struct {
+ name string
+ old string
+ }{
+ {"missing checksum is set", ""},
+ {"incorrect checksum is overridden", "TEST"},
+ {"correct checksum is kept", expected},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ conf := &SchedulerConfig{Checksum: tc.old}
+ SetChecksum(content, conf)
+ assert.Equal(t, expected, conf.Checksum, "checksum not
set to the calculated value")
+ })
+ }
+ // final nil check, must not panic
+ SetChecksum(content, nil)
+}
+
+func TestChecksumSerialisation(t *testing.T) {
+ conf, err := LoadSchedulerConfigFromByteArray([]byte(validConf))
+ assert.NilError(t, err, "unexpected error loading config")
+ // serialise the config the way it is stored in the config map,
partitions first, checksum line last
+ stored, err := yaml.Marshal(conf)
+ assert.NilError(t, err, "unexpected error serialising config")
+ // serialise again with an empty checksum, the checksum line is omitted
+ conf.Checksum = ""
+ noChecksum, err := yaml.Marshal(conf)
+ assert.NilError(t, err, "unexpected error serialising config without
checksum")
+ assert.Equal(t, string(noChecksum), GetConfigurationString(stored),
+ "stripped config does not match the serialisation without a
checksum")
+ // setChecksum check for the generated checksum
+ expected := fmt.Sprintf("%X", sha256.Sum256(noChecksum))
+ SetChecksum(stored, conf)
+ assert.Equal(t, expected, conf.Checksum,
+ "checksum from SetChecksum does not match the manual
recalculation")
+}
+
func prepareUserLimitsConfig(leafQueueMaxApps uint64, leafQueueMaxResource
string) string {
data := `
partitions:
diff --git a/pkg/scheduler/context.go b/pkg/scheduler/context.go
index 402a2c5f..de36655b 100644
--- a/pkg/scheduler/context.go
+++ b/pkg/scheduler/context.go
@@ -229,9 +229,12 @@ func (cc *ClusterContext) processRMConfigUpdateEvent(event
*rmevent.RMConfigUpda
event.Channel <- &rmevent.Result{Succeeded: false, Reason:
err.Error()}
return
}
- // skip update if config has not changed
+ // skip update if the config has not changed: the checksum is
calculated over the config again which lets
+ // us detect a real change even if the config map was updated for an
unrelated reason
oldConf := configs.ConfigContext.Get(cc.policyGroup)
- if conf.Checksum == oldConf.Checksum {
+ if oldConf != nil && conf.Checksum == oldConf.Checksum {
+ log.Log(log.SchedContext).Info("configuration checksum
unchanged, skipping config update",
+ zap.String("rmID", rmID), zap.String("checksum",
conf.Checksum))
event.Channel <- &rmevent.Result{
Succeeded: true,
}
diff --git a/pkg/webservice/dao/config_info.go
b/pkg/webservice/dao/config_info.go
index 39fdcd05..9f92bbe0 100644
--- a/pkg/webservice/dao/config_info.go
+++ b/pkg/webservice/dao/config_info.go
@@ -21,8 +21,10 @@ package dao
import "github.com/apache/yunikorn-core/pkg/common/configs"
type ValidateConfResponse struct {
- Allowed bool `json:"allowed"` // no omitempty, a false value gives a
quick way to understand the result.
- Reason string `json:"reason,omitempty"`
+ Allowed bool `json:"allowed"` // no omitempty, a false value
gives a quick way to understand the result.
+ Reason string `json:"reason,omitempty"`
+ Checksum string `json:"checksum,omitempty"` // checksum calculated
over the submitted configuration, empty when not allowed
+ ChecksumMatch bool `json:"checksumMatch"` // true when the
checksum in the submitted config matched the calculated checksum
}
type ConfigDAOInfo struct {
diff --git a/pkg/webservice/handlers.go b/pkg/webservice/handlers.go
index f07ab098..cb3421b2 100644
--- a/pkg/webservice/handlers.go
+++ b/pkg/webservice/handlers.go
@@ -169,16 +169,24 @@ func validateQueue(queuePath string) error {
func validateConf(w http.ResponseWriter, r *http.Request) {
writeHeaders(w, r.Method)
+ var result dao.ValidateConfResponse
requestBytes, err := io.ReadAll(r.Body)
if err == nil {
- _, err = configs.LoadSchedulerConfigFromByteArray(requestBytes)
+ var conf *configs.SchedulerConfig
+ conf, err = configs.ParseAndValidateConfig(requestBytes)
+ if err == nil {
+ // capture the checksum that was submitted with the
config before it is overridden with the calculated one,
+ // to report whether the submitted checksum was correct
+ submittedChecksum := conf.Checksum
+ configs.SetChecksum(requestBytes, conf)
+ result.Allowed = true
+ result.Checksum = conf.Checksum
+ result.ChecksumMatch = submittedChecksum ==
conf.Checksum
+ }
}
- var result dao.ValidateConfResponse
if err != nil {
result.Allowed = false
result.Reason = err.Error()
- } else {
- result.Allowed = true
}
if err = json.NewEncoder(w).Encode(result); err != nil {
buildJSONErrorResponse(w, err.Error(),
http.StatusInternalServerError)
diff --git a/pkg/webservice/handlers_test.go b/pkg/webservice/handlers_test.go
index c17a39b7..8a7f510f 100644
--- a/pkg/webservice/handlers_test.go
+++ b/pkg/webservice/handlers_test.go
@@ -392,6 +392,39 @@ func TestValidateConf(t *testing.T) {
}
}
+func TestValidateConfChecksum(t *testing.T) {
+ conf, err := configs.LoadSchedulerConfigFromByteArray([]byte(baseConf))
+ assert.NilError(t, err, "unexpected error loading base config")
+ expected := conf.Checksum
+
+ confTests := []struct {
+ name string
+ content string
+ wantAllowed bool
+ wantChecksum string
+ wantChkMatch bool
+ }{
+ {"no checksum in config", baseConf, true, expected, false},
+ {"correct checksum in config", baseConf + "checksum: " +
expected + "\n", true, expected, true},
+ {"incorrect checksum in config", baseConf + "checksum: TEST\n",
true, expected, false},
+ {"invalid config has no checksum", invalidConf, false, "",
false},
+ }
+ for _, test := range confTests {
+ t.Run(test.name, func(t *testing.T) {
+ req, err := http.NewRequest("POST", "",
strings.NewReader(test.content))
+ assert.NilError(t, err, "new http request must not
return an error")
+ resp := &MockResponseWriter{}
+ validateConf(resp, req)
+ var vcr dao.ValidateConfResponse
+ err = json.Unmarshal(resp.outputBytes, &vcr)
+ assert.NilError(t, err, unmarshalError)
+ assert.Equal(t, vcr.Allowed, test.wantAllowed, "allowed
flag incorrect")
+ assert.Equal(t, vcr.Checksum, test.wantChecksum,
"checksum not as expected")
+ assert.Equal(t, vcr.ChecksumMatch, test.wantChkMatch,
"checksum match flag not as expected")
+ })
+ }
+}
+
func TestUserGroupLimits(t *testing.T) {
confTests := []struct {
content string
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]