diff --git a/cmd/metal-api/internal/datastore/machine.go b/cmd/metal-api/internal/datastore/machine.go index 6772de020..dcc32a2d2 100644 --- a/cmd/metal-api/internal/datastore/machine.go +++ b/cmd/metal-api/internal/datastore/machine.go @@ -4,11 +4,12 @@ import ( "crypto/rand" "errors" "fmt" + "math" "math/big" - r "gopkg.in/rethinkdb/rethinkdb-go.v6" - "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + "golang.org/x/exp/slices" + r "gopkg.in/rethinkdb/rethinkdb-go.v6" ) // MachineSearchQuery can be used to search machines. @@ -427,7 +428,7 @@ func (rs *RethinkStore) UpdateMachine(oldMachine *metal.Machine, newMachine *met // FindWaitingMachine returns an available, not allocated, waiting and alive machine of given size within the given partition. // TODO: the algorithm can be optimized / shortened by using a rethinkdb join command and then using .Sample(1) // but current implementation should have a slightly better readability. -func (rs *RethinkStore) FindWaitingMachine(partitionid, sizeid string) (*metal.Machine, error) { +func (rs *RethinkStore) FindWaitingMachine(projectid, partitionid, sizeid string, placementTags []string) (*metal.Machine, error) { q := *rs.machineTable() q = q.Filter(map[string]interface{}{ "allocation": nil, @@ -470,20 +471,151 @@ func (rs *RethinkStore) FindWaitingMachine(partitionid, sizeid string) (*metal.M return nil, errors.New("no machine available") } - // pick a random machine from all available ones - var idx int - b, err := rand.Int(rand.Reader, big.NewInt(int64(len(available)))) + query := MachineSearchQuery{ + AllocationProject: &projectid, + PartitionID: &partitionid, + } + + var projectMachines metal.Machines + err = rs.SearchMachines(&query, &projectMachines) if err != nil { return nil, err } - idx = int(b.Uint64()) - oldMachine := available[idx] + spreadCandidates := spreadAcrossRacks(available, projectMachines, placementTags) + if len(spreadCandidates) == 0 { + return nil, errors.New("no machine available") + } + + oldMachine := spreadCandidates[randomIndex(len(spreadCandidates))] newMachine := oldMachine newMachine.PreAllocated = true + err = rs.updateEntity(rs.machineTable(), &newMachine, &oldMachine) if err != nil { return nil, err } + return &newMachine, nil } + +func spreadAcrossRacks(allMachines, projectMachines metal.Machines, tags []string) metal.Machines { + var ( + allRacks = groupByRack(allMachines) + + projectRacks = groupByRack(projectMachines) + leastOccupiedByProjectRacks = electRacks(allRacks, projectRacks) + + taggedMachines = groupByTags(projectMachines).filter(tags...).getMachines() + taggedRacks = groupByRack(taggedMachines) + leastOccupiedByTagsRacks = electRacks(allRacks, taggedRacks) + + intersection = intersect(leastOccupiedByTagsRacks, leastOccupiedByProjectRacks) + ) + + if c := allRacks.filter(intersection...).getMachines(); len(c) > 0 { + return c + } + + return allRacks.filter(leastOccupiedByTagsRacks...).getMachines() // tags have precedence over project +} + +type groupedMachines map[string]metal.Machines + +func (g groupedMachines) getMachines() metal.Machines { + machines := make(metal.Machines, 0) + + for id := range g { + machines = append(machines, g[id]...) + } + + return machines +} + +func (g groupedMachines) filter(keys ...string) groupedMachines { + result := make(groupedMachines) + + for i := range keys { + ms, ok := g[keys[i]] + if ok { + result[keys[i]] = ms + } + } + + return result +} + +func groupByRack(machines metal.Machines) groupedMachines { + racks := make(groupedMachines) + + for _, m := range machines { + racks[m.RackID] = append(racks[m.RackID], m) + } + + return racks +} + +// electRacks returns the least occupied racks from all racks +func electRacks(allRacks, occupiedRacks groupedMachines) []string { + winners := make([]string, 0) + min := math.MaxInt + + for id := range allRacks { + if _, ok := occupiedRacks[id]; ok { + continue + } + occupiedRacks[id] = nil + } + + for id := range occupiedRacks { + if _, ok := allRacks[id]; !ok { + continue + } + + switch { + case len(occupiedRacks[id]) < min: + min = len(occupiedRacks[id]) + winners = []string{id} + case len(occupiedRacks[id]) == min: + winners = append(winners, id) + } + } + + return winners +} + +func groupByTags(machines metal.Machines) groupedMachines { + groups := make(groupedMachines) + + for _, m := range machines { + for j := range m.Tags { + ms := groups[m.Tags[j]] + groups[m.Tags[j]] = append(ms, m) + } + } + + return groups +} + +func randomIndex(max int) int { + if max <= 0 { + return 0 + } + + b, _ := rand.Int(rand.Reader, big.NewInt(int64(max))) + idx := int(b.Uint64()) + + return idx +} + +func intersect[T comparable](a, b []T) []T { + c := make([]T, 0) + + for i := range a { + if slices.Contains(b, a[i]) { + c = append(c, a[i]) + } + } + + return c +} diff --git a/cmd/metal-api/internal/datastore/machine_test.go b/cmd/metal-api/internal/datastore/machine_test.go index 0d4ce62de..f030cb684 100644 --- a/cmd/metal-api/internal/datastore/machine_test.go +++ b/cmd/metal-api/internal/datastore/machine_test.go @@ -1,10 +1,15 @@ package datastore import ( + "reflect" + "sort" "testing" "testing/quick" + "github.com/google/go-cmp/cmp" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" "github.com/metal-stack/metal-api/cmd/metal-api/internal/testdata" + "golang.org/x/exp/slices" ) // Test that generates many input data @@ -25,3 +30,691 @@ func TestRethinkStore_FindMachineByIDQuick(t *testing.T) { t.Error(err) } } + +func Test_groupByRack(t *testing.T) { + type args struct { + machines metal.Machines + } + tests := []struct { + name string + args args + want groupedMachines + }{ + { + name: "no machines", + args: args{ + machines: metal.Machines{}, + }, + want: groupedMachines{}, + }, + { + name: "racks of size 1", + args: args{ + machines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + }, + }, + want: groupedMachines{ + "1": {{RackID: "1"}}, + "2": {{RackID: "2"}}, + "3": {{RackID: "3"}}, + }, + }, + { + name: "bigger racks", + args: args{ + machines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + }, + }, + want: groupedMachines{ + "1": { + {RackID: "1"}, + {RackID: "1"}, + {RackID: "1"}, + }, + "2": { + {RackID: "2"}, + {RackID: "2"}, + {RackID: "2"}, + }, + "3": { + {RackID: "3"}, + {RackID: "3"}, + {RackID: "3"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if diff := cmp.Diff(groupByRack(tt.args.machines), tt.want); diff != "" { + t.Errorf("groupByRack() = %s", diff) + } + }) + } +} + +func Test_groupByTags(t *testing.T) { + type args struct { + machines metal.Machines + } + tests := []struct { + name string + args args + want groupedMachines + }{ + { + name: "one machine with no tags", + args: args{ + machines: metal.Machines{{}}, + }, + want: groupedMachines{}, + }, + { + name: "one machine with multiple tags", + args: args{ + machines: metal.Machines{ + {Tags: []string{"1", "2", "3", "4"}}, + }, + }, + want: groupedMachines{ + "1": { + {Tags: []string{"1", "2", "3", "4"}}, + }, + "2": { + {Tags: []string{"1", "2", "3", "4"}}, + }, + "3": { + {Tags: []string{"1", "2", "3", "4"}}, + }, + "4": { + {Tags: []string{"1", "2", "3", "4"}}, + }, + }, + }, + { + name: "multiple machines with intersecting tags", + args: args{ + machines: metal.Machines{ + {Tags: []string{"1", "2", "3"}}, + {Tags: []string{"1", "2", "4"}}, + }, + }, + want: groupedMachines{ + "1": { + {Tags: []string{"1", "2", "3"}}, + {Tags: []string{"1", "2", "4"}}, + }, + "2": { + {Tags: []string{"1", "2", "3"}}, + {Tags: []string{"1", "2", "4"}}, + }, + "3": { + {Tags: []string{"1", "2", "3"}}, + }, + "4": { + {Tags: []string{"1", "2", "4"}}, + }, + }, + }, + { + name: "multiple machines with disjunct tags", + args: args{ + machines: metal.Machines{ + {Tags: []string{"1", "2", "3"}}, + {Tags: []string{"4", "5", "6"}}, + }, + }, + want: groupedMachines{ + "1": { + {Tags: []string{"1", "2", "3"}}, + }, + "2": { + {Tags: []string{"1", "2", "3"}}, + }, + "3": { + {Tags: []string{"1", "2", "3"}}, + }, + "4": { + {Tags: []string{"4", "5", "6"}}, + }, + "5": { + {Tags: []string{"4", "5", "6"}}, + }, + "6": { + {Tags: []string{"4", "5", "6"}}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := groupByTags(tt.args.machines); !reflect.DeepEqual(got, tt.want) { + t.Errorf("groupByTags() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_electRacks(t *testing.T) { + type args struct { + allRacks groupedMachines + occupiedRacks groupedMachines + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "no racks", + args: args{ + allRacks: groupedMachines{}, + occupiedRacks: groupedMachines{}, + }, + want: []string{}, + }, + { + name: "one winner", + args: args{ + allRacks: groupedMachines{ + "1": nil, + "2": nil, + "3": nil, + "4": nil, + }, + occupiedRacks: groupedMachines{ + "1": {{}, {}, {}}, + "2": {{}, {}}, + "3": {{}}, + "4": {}, + }, + }, + want: []string{"4"}, + }, + { + name: "two winners", + args: args{ + allRacks: groupedMachines{ + "1": nil, + "2": nil, + "3": nil, + "4": nil, + }, + occupiedRacks: groupedMachines{ + "1": {{}, {}, {}}, + "2": {{}, {}, {}}, + "3": {{}}, + "4": {{}}, + }, + }, + want: []string{"3", "4"}, + }, + { + name: "considering non occupied racks as well", + args: args{ + allRacks: groupedMachines{ + "1": nil, + "2": nil, + "3": nil, + "5": nil, + }, + occupiedRacks: groupedMachines{ + "1": {{}, {}, {}}, + "2": {{}, {}, {}}, + "3": {{}}, + "4": {{}}, + }, + }, + want: []string{"5"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := electRacks(tt.args.allRacks, tt.args.occupiedRacks) + slices.Sort(got) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("electRacks() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_filter(t *testing.T) { + type args struct { + machines groupedMachines + keys []string + } + tests := []struct { + name string + args args + want groupedMachines + }{ + { + name: "empty map", + args: args{ + machines: groupedMachines{}, + keys: []string{"1", "2", "3"}, + }, + want: groupedMachines{}, + }, + { + name: "idempotent", + args: args{ + machines: groupedMachines{ + "1": {{Tags: []string{"1"}}}, + "2": {{Tags: []string{"2"}}}, + "3": {{Tags: []string{"3"}}}, + }, + keys: []string{"1", "2", "3"}, + }, + want: groupedMachines{ + "1": {{Tags: []string{"1"}}}, + "2": {{Tags: []string{"2"}}}, + "3": {{Tags: []string{"3"}}}, + }, + }, + { + name: "return empty", + args: args{ + machines: groupedMachines{ + "1": {{Tags: []string{"1"}}}, + "2": {{Tags: []string{"2"}}}, + "3": {{Tags: []string{"3"}}}, + }, + keys: []string{"4", "5", "6"}, + }, + want: groupedMachines{}, + }, + { + name: "return filtered", + args: args{ + machines: groupedMachines{ + "1": {{Tags: []string{"1"}}}, + "2": {{Tags: []string{"2"}}}, + "3": {{Tags: []string{"3"}}}, + }, + keys: []string{"1", "5", "6"}, + }, + want: groupedMachines{ + "1": {{Tags: []string{"1"}}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.args.machines.filter(tt.args.keys...); !reflect.DeepEqual(got, tt.want) { + t.Errorf("filter() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_spreadAcrossRacks(t *testing.T) { + type args struct { + allMachines metal.Machines + projectMachines metal.Machines + tags []string + } + tests := []struct { + name string + args args + want metal.Machines + }{ + { + name: "one available machine", + args: args{ + allMachines: metal.Machines{{RackID: "1"}}, + projectMachines: metal.Machines{{RackID: "1", Tags: []string{"tag"}}}, + tags: []string{"tag"}, + }, + want: metal.Machines{{RackID: "1"}}, + }, + { + name: "no project machines", + args: args{ + allMachines: metal.Machines{{RackID: "1"}, {RackID: "2"}}, + projectMachines: metal.Machines{}, + tags: []string{}, + }, + want: metal.Machines{{RackID: "1"}, {RackID: "2"}}, + }, + { + name: "no tags, spread by project only", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + }, + projectMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "1"}, + {RackID: "2", Tags: []string{"tag"}}, + }, + tags: []string{}, + }, + want: metal.Machines{{RackID: "2"}}, + }, + { + name: "no tags and preferred racks aren't available", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + }, + projectMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "1"}, + {RackID: "2"}, + }, + tags: []string{}, + }, + want: metal.Machines{{RackID: "1"}}, + }, + { + name: "spread by tags", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + }, + projectMachines: metal.Machines{ + {RackID: "1", Tags: []string{"tag1"}}, + {RackID: "2", Tags: []string{"irrelevant-tag"}}, + {RackID: "2"}, + }, + tags: []string{"tag1"}, + }, + want: metal.Machines{{RackID: "2"}}, + }, + { + name: "no machines match relevant tags", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + }, + projectMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2", Tags: []string{"irrelevant-tag"}}, + {RackID: "2"}, + }, + tags: []string{"tag1"}, + }, + want: metal.Machines{{RackID: "1"}}, + }, + { + name: "two racks in a draw, let project decide", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + }, + projectMachines: metal.Machines{ + {RackID: "1", Tags: []string{"cluster1"}}, + {RackID: "2", Tags: []string{"cluster1"}}, + {RackID: "2", Tags: []string{}}, + {RackID: "3", Tags: []string{"cluster1", "postgres"}}, + }, + tags: []string{"cluster1", "postgres"}, + }, + want: metal.Machines{{RackID: "1"}}, + }, + { + name: "equal tag affinity for all racks", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + }, + projectMachines: metal.Machines{ + {RackID: "1", Tags: []string{"cluster1"}}, + {RackID: "2", Tags: []string{"cluster1"}}, + {RackID: "3", Tags: []string{"postgres"}}, + }, + tags: []string{"cluster1", "postgres"}, + }, + want: metal.Machines{{RackID: "1"}, {RackID: "2"}, {RackID: "3"}}, + }, + { + name: "racks with fewer tags win", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + }, + projectMachines: metal.Machines{ + {RackID: "1", Tags: []string{"cluster1"}}, + {RackID: "2", Tags: []string{"cluster1"}}, + {RackID: "3", Tags: []string{"cluster1", "postgres"}}, + }, + tags: []string{"cluster1", "postgres"}, + }, + want: metal.Machines{{RackID: "1"}, {RackID: "2"}}, + }, + { + name: "preferred racks aren't available", + args: args{ + allMachines: metal.Machines{ + {RackID: "3"}, + }, + projectMachines: metal.Machines{ + {RackID: "1", Tags: []string{"tag1"}}, + {RackID: "2", Tags: []string{"tag1"}}, + {RackID: "3", Tags: []string{"tag1", "tag2"}}, + {RackID: "3", Tags: []string{"tag2"}}, + {RackID: "2", Tags: []string{"tag2"}}, + }, + tags: []string{"tag1", "tag2"}, + }, + want: metal.Machines{{RackID: "3"}}, + }, + { + name: "racks preferred by tags aren't available, choose by project", + args: args{ + allMachines: metal.Machines{ + {RackID: "3"}, + {RackID: "2"}, + {RackID: "2"}, + {RackID: "2"}, + {RackID: "2"}, + {RackID: "2"}, + {RackID: "2"}, + }, + projectMachines: metal.Machines{ + {RackID: "1", Tags: []string{"tag1"}}, + {RackID: "2", Tags: []string{"tag1"}}, + {RackID: "2", Tags: []string{"tag1"}}, + {RackID: "3", Tags: []string{"tag1"}}, + {RackID: "3", Tags: []string{"tag1"}}, + {RackID: "1"}, + {RackID: "1"}, + {RackID: "2"}, + }, + tags: []string{"tag1"}, + }, + want: metal.Machines{{RackID: "3"}}, + }, + { + name: "two winners", + args: args{ + allMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + }, + projectMachines: metal.Machines{ + {RackID: "1"}, + {RackID: "1"}, + {RackID: "1"}, + {RackID: "2"}, + {RackID: "3"}, + {RackID: "3"}, + {RackID: "2"}, + }, + tags: []string{}, + }, + want: metal.Machines{{RackID: "2"}, {RackID: "3"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + machines := spreadAcrossRacks(tt.args.allMachines, tt.args.projectMachines, tt.args.tags) + sort.SliceStable(machines, func(i, j int) bool { + return machines[i].RackID < machines[j].RackID + }) + + if diff := cmp.Diff(machines, tt.want); diff != "" { + t.Errorf("spreadAcrossRacks() = %s", diff) + } + }) + } +} + +func Test_intersect(t *testing.T) { + type args struct { + a []string + b []string + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "both empty", + want: []string{}, + }, + { + name: "one empty", + args: args{ + a: []string{""}, + b: []string{}, + }, + want: []string{}, + }, + { + name: "empty intersection", + args: args{ + a: []string{"1"}, + b: []string{"2"}, + }, + want: []string{}, + }, + { + name: "non-empty intersection", + args: args{ + a: []string{"1", "2", "3"}, + b: []string{"1", "3", "4"}, + }, + want: []string{"1", "3"}, + }, + { + name: "intersection equals a", + args: args{ + a: []string{"3", "2", "1"}, + b: []string{"1", "3", "4", "2"}, + }, + want: []string{"1", "2", "3"}, + }, + { + name: "intersection contains same elements as b", + args: args{ + a: []string{"3", "2", "1", "4"}, + b: []string{"1", "3", "4"}, + }, + want: []string{"1", "3", "4"}, + }, + { + name: "a and b contain same elements", + args: args{ + a: []string{"3", "2", "1", "4"}, + b: []string{"1", "3", "4", "2"}, + }, + want: []string{"1", "2", "3", "4"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := intersect(tt.args.a, tt.args.b) + slices.Sort(got) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("intersect() = %v, want %v", got, tt.want) + } + }) + } +} + +func BenchmarkElectMachine(b *testing.B) { + type args struct { + allMachines metal.Machines + projectMachines metal.Machines + tags []string + } + tests := []struct { + name string + args args + }{ + { + name: "10 available, 11 project", + args: args{ + allMachines: getTestMachines(2, []string{"1", "2", "3", "4", "5"}, []string{}), + projectMachines: append(getTestMachines(2, []string{"1", "2", "3", "4", "5"}, []string{"tag1", "tag2", "tag3", "tag4"}), getTestMachines(1, []string{"6"}, []string{})...), + tags: []string{"tag1", "tag2", "tag3", "tag4"}, + }, + }, + { + name: "100 available", + args: args{ + allMachines: getTestMachines(20, []string{"1", "2", "3", "4", "5"}, []string{}), + projectMachines: append(getTestMachines(20, []string{"1", "2", "3", "4", "5"}, []string{"tag1", "tag2", "tag3", "tag4"}), getTestMachines(10, []string{"6"}, []string{})...), + tags: []string{"tag1", "tag2", "tag3", "tag4"}, + }, + }, + { + name: "1000 available, 1100 project", + args: args{ + allMachines: getTestMachines(200, []string{"1", "2", "3", "4", "5"}, []string{}), + projectMachines: append(getTestMachines(200, []string{"1", "2", "3", "4", "5"}, []string{"tag1", "tag2", "tag3", "tag4"}), getTestMachines(100, []string{"6"}, []string{})...), + tags: []string{"tag1", "tag2", "tag3", "tag4"}, + }, + }, + } + for _, t := range tests { + b.Run(t.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + spreadAcrossRacks(t.args.allMachines, t.args.projectMachines, t.args.tags) + } + }) + } +} + +func getTestMachines(numPerRack int, rackids []string, tags []string) metal.Machines { + machines := make(metal.Machines, 0) + + for _, id := range rackids { + for i := 0; i < numPerRack; i++ { + m := metal.Machine{ + RackID: id, + Tags: tags, + } + + machines = append(machines, m) + } + } + + return machines +} diff --git a/cmd/metal-api/internal/service/machine-service.go b/cmd/metal-api/internal/service/machine-service.go index df06f2d24..ac1caef44 100644 --- a/cmd/metal-api/internal/service/machine-service.go +++ b/cmd/metal-api/internal/service/machine-service.go @@ -74,6 +74,7 @@ type machineAllocationSpec struct { IPs []string Role metal.Role VPN *metal.MachineVPN + PlacementTags []string } // allocationNetwork is intermediate struct to create machine networks from regular networks during machine allocation @@ -937,6 +938,7 @@ func createMachineAllocationSpec(ds *datastore.RethinkStore, requestPayload v1.M IPs: requestPayload.IPs, Role: role, FilesystemLayoutID: requestPayload.FilesystemLayoutID, + PlacementTags: requestPayload.PlacementTags, }, nil } @@ -1138,7 +1140,7 @@ func findMachineCandidate(ds *datastore.RethinkStore, allocationSpec *machineAll var machine *metal.Machine if allocationSpec.Machine == nil { // requesting allocation of an arbitrary ready machine in partition with given size - machine, err = findWaitingMachine(ds, allocationSpec.PartitionID, allocationSpec.Size.ID) + machine, err = findWaitingMachine(ds, allocationSpec) if err != nil { return nil, err } @@ -1159,16 +1161,17 @@ func findMachineCandidate(ds *datastore.RethinkStore, allocationSpec *machineAll return machine, err } -func findWaitingMachine(ds *datastore.RethinkStore, partitionID, sizeID string) (*metal.Machine, error) { - size, err := ds.FindSize(sizeID) +func findWaitingMachine(ds *datastore.RethinkStore, allocationSpec *machineAllocationSpec) (*metal.Machine, error) { + size, err := ds.FindSize(allocationSpec.Size.ID) if err != nil { return nil, fmt.Errorf("size cannot be found: %w", err) } - partition, err := ds.FindPartition(partitionID) + partition, err := ds.FindPartition(allocationSpec.PartitionID) if err != nil { return nil, fmt.Errorf("partition cannot be found: %w", err) } - machine, err := ds.FindWaitingMachine(partition.ID, size.ID) + + machine, err := ds.FindWaitingMachine(allocationSpec.ProjectID, partition.ID, size.ID, allocationSpec.PlacementTags) if err != nil { return nil, err } diff --git a/cmd/metal-api/internal/service/v1/machine.go b/cmd/metal-api/internal/service/v1/machine.go index c8817a239..48e164ec8 100644 --- a/cmd/metal-api/internal/service/v1/machine.go +++ b/cmd/metal-api/internal/service/v1/machine.go @@ -194,6 +194,7 @@ type MachineAllocateRequest struct { Tags []string `json:"tags" description:"tags for this machine" optional:"true"` Networks MachineAllocationNetworks `json:"networks" description:"the networks that this machine will be placed in." optional:"true"` IPs []string `json:"ips" description:"the ips to attach to this machine additionally" optional:"true"` + PlacementTags []string `json:"placement_tags,omitempty" description:"by default machines are spread across the racks inside a partition for every project. if placement tags are provided, the machine candidate has an additional anti-affinity to other machines having the same tags"` } type MachineAllocationNetworks []MachineAllocationNetwork diff --git a/go.mod b/go.mod index 1d8925389..be035a91b 100644 --- a/go.mod +++ b/go.mod @@ -163,7 +163,7 @@ require ( go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20230303233057-f1b76eb4bb35 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20230221090011-e4bae7ad2296 // indirect - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.12.0 // indirect golang.org/x/oauth2 v0.10.0 // indirect diff --git a/spec/metal-api.json b/spec/metal-api.json index 7e78f1643..9c75236c0 100644 --- a/spec/metal-api.json +++ b/spec/metal-api.json @@ -1055,6 +1055,13 @@ "description": "the partition id to assign this machine to", "type": "string" }, + "placement_tags": { + "description": "by default machines are spread across the racks inside a partition for every project. if placement tags are provided, the machine candidate has an additional anti-affinity to other machines having the same tags", + "items": { + "type": "string" + }, + "type": "array" + }, "projectid": { "description": "the project id to assign this machine to", "type": "string" @@ -1939,6 +1946,13 @@ "description": "the partition id to assign this machine to", "type": "string" }, + "placement_tags": { + "description": "by default machines are spread across the racks inside a partition for every project. if placement tags are provided, the machine candidate has an additional anti-affinity to other machines having the same tags", + "items": { + "type": "string" + }, + "type": "array" + }, "projectid": { "description": "the project id to assign this machine to", "type": "string"