diff --git a/framework/state.go b/framework/state.go index e186419..914ac8d 100644 --- a/framework/state.go +++ b/framework/state.go @@ -20,6 +20,7 @@ import ( "maps" "reflect" "slices" + "sort" score "github.com/score-spec/score-go/types" ) @@ -120,6 +121,15 @@ func uuidV4() string { return fmt.Sprintf("%x-%x-%x-%x-%x", d[:4], d[4:6], d[6:8], d[8:10], d[10:]) } +func sortedStringMapKeys[v any](input map[string]v) []string { + out := make([]string, 0, len(input)) + for s := range input { + out = append(out, s) + } + sort.Strings(out) + return out +} + // WithPrimedResources returns a new copy of State with all workload resources resolved to at least their initial type, // class and id. New resources will have an empty provider set. Existing resources will not be touched. // This is not a deep copy, but any writes are executed in a copy-on-write manner to avoid modifying the source. @@ -132,8 +142,10 @@ func (s *State[StateExtras, WorkloadExtras, ResourceExtras]) WithPrimedResources } primedResourceUids := make(map[ResourceUid]bool) - for workloadName, workload := range s.Workloads { - for resName, res := range workload.Spec.Resources { + for _, workloadName := range sortedStringMapKeys(s.Workloads) { + workload := s.Workloads[workloadName] + for _, resName := range sortedStringMapKeys(workload.Spec.Resources) { + res := workload.Spec.Resources[resName] resUid := NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id) if existing, ok := out.Resources[resUid]; !ok { out.Resources[resUid] = ScoreResourceState[ResourceExtras]{ diff --git a/framework/state_test.go b/framework/state_test.go index b506f54..ced4a67 100644 --- a/framework/state_test.go +++ b/framework/state_test.go @@ -253,8 +253,7 @@ resources: }) t.Run("two workload - nominal", func(t *testing.T) { - t.Run("one workload - nominal", func(t *testing.T) { - next := mustAddWorkload(t, start, ` + next := mustAddWorkload(t, start, ` metadata: {"name": "example1"} resources: one: @@ -263,7 +262,7 @@ resources: type: thing2 id: dog `) - next = mustAddWorkload(t, next, ` + next = mustAddWorkload(t, next, ` metadata: {"name": "example2"} resources: one: @@ -272,32 +271,31 @@ resources: type: thing2 id: dog `) - next, err := next.WithPrimedResources() - require.NoError(t, err) - assert.Len(t, start.Resources, 0) - assert.Len(t, next.Resources, 3) - checkAndResetGuids(t, next.Resources) - assert.Equal(t, map[ResourceUid]ScoreResourceState[NoExtras]{ - "thing.default#example1.one": { - Guid: "00000000-0000-0000-0000-000000000000", - Type: "thing", Class: "default", Id: "example1.one", State: map[string]interface{}{}, - SourceWorkload: "example1", - Outputs: map[string]interface{}{}, - }, - "thing.default#example2.one": { - Guid: "00000000-0000-0000-0000-000000000000", - Type: "thing", Class: "default", Id: "example2.one", State: map[string]interface{}{}, - SourceWorkload: "example2", - Outputs: map[string]interface{}{}, - }, - "thing2.default#dog": { - Guid: "00000000-0000-0000-0000-000000000000", - Type: "thing2", Class: "default", Id: "dog", State: map[string]interface{}{}, - SourceWorkload: "example1", - Outputs: map[string]interface{}{}, - }, - }, next.Resources) - }) + next, err := next.WithPrimedResources() + require.NoError(t, err) + assert.Len(t, start.Resources, 0) + assert.Len(t, next.Resources, 3) + checkAndResetGuids(t, next.Resources) + assert.Equal(t, map[ResourceUid]ScoreResourceState[NoExtras]{ + "thing.default#example1.one": { + Guid: "00000000-0000-0000-0000-000000000000", + Type: "thing", Class: "default", Id: "example1.one", State: map[string]interface{}{}, + SourceWorkload: "example1", + Outputs: map[string]interface{}{}, + }, + "thing.default#example2.one": { + Guid: "00000000-0000-0000-0000-000000000000", + Type: "thing", Class: "default", Id: "example2.one", State: map[string]interface{}{}, + SourceWorkload: "example2", + Outputs: map[string]interface{}{}, + }, + "thing2.default#dog": { + Guid: "00000000-0000-0000-0000-000000000000", + Type: "thing2", Class: "default", Id: "dog", State: map[string]interface{}{}, + SourceWorkload: "example1", + Outputs: map[string]interface{}{}, + }, + }, next.Resources) }) } diff --git a/loader/normalize.go b/loader/normalize.go index cafa822..54bc837 100644 --- a/loader/normalize.go +++ b/loader/normalize.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "path/filepath" + "unicode/utf8" "github.com/score-spec/score-go/types" ) @@ -53,5 +54,9 @@ func readFile(baseDir, path string) (string, error) { return "", err } + if !utf8.Valid(raw) { + return "", fmt.Errorf("file contains non-utf8 characters") + } + return string(raw), nil } diff --git a/types/types.go b/types/types.go index 7e7a252..51ef4ef 100644 --- a/types/types.go +++ b/types/types.go @@ -14,6 +14,12 @@ package types +import ( + "fmt" + "strconv" + "strings" +) + //go:generate go run github.com/atombender/go-jsonschema@v0.15.0 -v --schema-output=https://score.dev/schemas/score=types.gen.go --schema-package=https://score.dev/schemas/score=types --schema-root-type=https://score.dev/schemas/score=Workload ../schema/files/score-v1b1.json.modified func (m *ResourceMetadata) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -33,3 +39,52 @@ func (m *WorkloadMetadata) UnmarshalYAML(unmarshal func(interface{}) error) erro *m = WorkloadMetadata(out) return nil } + +// findIPowerSuffix is used in ParseResourceLimits to parse memory units. +func findIPowerSuffix(raw string, suffi []string, base int64) (suffix string, m int64) { + m = 1 + for _, s := range suffi { + m *= base + if strings.HasSuffix(raw, s) { + return s, m + } + } + return "", 0 +} + +// ParseResourceLimits parses a resource limits definition into milli-cpus and memory bytes if present. +// For example, 500m cpus = 500 millicpus, while 2 cpus = 2000 cpus. 1M == 1000000 bytes of memory, while 1Ki = 1024. +func ParseResourceLimits(rl ResourcesLimits) (milliCpus *int, memoryBytes *int64, err error) { + if rl.Cpu != nil { + isMilli := strings.HasSuffix(*rl.Cpu, "m") + c := strings.TrimSuffix(*rl.Cpu, "m") + v, err := strconv.ParseFloat(c, 64) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse cpus '%s' as a number", c) + } + if !isMilli { + v *= 1000 + } + iv := int(v) + milliCpus = &iv + } + if rl.Memory != nil { + // https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/#memory-units + raw := *rl.Memory + var multiplier int64 = 1 + if s, m := findIPowerSuffix(raw, []string{"K", "M", "G", "T"}, 1000); m > 0 { + raw = strings.TrimSuffix(raw, s) + multiplier = m + } else if s, m = findIPowerSuffix(raw, []string{"Ki", "Mi", "Gi", "Ti"}, 1024); m > 0 { + raw = strings.TrimSuffix(raw, s) + multiplier = m + } + if v, err := strconv.ParseInt(raw, 10, 64); err != nil { + return nil, nil, fmt.Errorf("failed to parse memory '%s' as a number", raw) + } else { + v *= multiplier + memoryBytes = &v + } + } + return +} diff --git a/types/types_test.go b/types/types_test.go new file mode 100644 index 0000000..4bd324b --- /dev/null +++ b/types/types_test.go @@ -0,0 +1,46 @@ +// Copyright 2020 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Ref[k any](in k) *k { + return &in +} + +func DerefOr[k any](in *k, def k) k { + if in == nil { + return def + } + return *in +} + +func parseAndFormatResourceLimits(rl ResourcesLimits) string { + c, m, err := ParseResourceLimits(rl) + return fmt.Sprintf("%d %d %v", DerefOr(c, -1), DerefOr(m, -1), err) +} + +func TestParseResourceLimits(t *testing.T) { + assert.Equal(t, "-1 -1 ", parseAndFormatResourceLimits(ResourcesLimits{})) + assert.Equal(t, "1000 1000000 ", parseAndFormatResourceLimits(ResourcesLimits{Cpu: Ref("1"), Memory: Ref("1M")})) + assert.Equal(t, "-1 -1 failed to parse cpus 'banana' as a number", parseAndFormatResourceLimits(ResourcesLimits{Cpu: Ref("banana"), Memory: nil})) + assert.Equal(t, "-1 -1 failed to parse memory 'banana' as a number", parseAndFormatResourceLimits(ResourcesLimits{Cpu: nil, Memory: Ref("banana")})) + assert.Equal(t, "200 128974848 ", parseAndFormatResourceLimits(ResourcesLimits{Cpu: Ref("200m"), Memory: Ref("123Mi")})) +}