Merge pull request #6627 from thaJeztah/local_parsegeneric_resource

cli/command/service: parse generic resources without protobufs
This commit is contained in:
Sebastiaan van Stijn
2025-11-05 12:45:12 +01:00
committed by GitHub
10 changed files with 157 additions and 491 deletions

View File

@ -4,9 +4,8 @@ import (
"fmt"
"strings"
"github.com/docker/cli/cli/command/service/internal/genericresource"
"github.com/moby/moby/api/types/swarm"
swarmapi "github.com/moby/swarmkit/v2/api"
"github.com/moby/swarmkit/v2/api/genericresource"
)
// GenericResource is a concept that a user can use to advertise user-defined
@ -33,12 +32,11 @@ func ParseGenericResources(value []string) ([]swarm.GenericResource, error) {
return nil, nil
}
resources, err := genericresource.Parse(value)
swarmResources, err := genericresource.Parse(value)
if err != nil {
return nil, fmt.Errorf("invalid generic resource specification: %w", err)
}
swarmResources := genericResourcesFromGRPC(resources)
for _, res := range swarmResources {
if res.NamedResourceSpec != nil {
return nil, fmt.Errorf("invalid generic-resource request `%s=%s`, Named Generic Resources is not supported for service create or update",
@ -50,31 +48,6 @@ func ParseGenericResources(value []string) ([]swarm.GenericResource, error) {
return swarmResources, nil
}
// genericResourcesFromGRPC converts a GRPC GenericResource to a GenericResource
func genericResourcesFromGRPC(genericRes []*swarmapi.GenericResource) []swarm.GenericResource {
generic := make([]swarm.GenericResource, 0, len(genericRes))
for _, res := range genericRes {
var current swarm.GenericResource
switch r := res.Resource.(type) {
case *swarmapi.GenericResource_DiscreteResourceSpec:
current.DiscreteResourceSpec = &swarm.DiscreteGenericResource{
Kind: r.DiscreteResourceSpec.Kind,
Value: r.DiscreteResourceSpec.Value,
}
case *swarmapi.GenericResource_NamedResourceSpec:
current.NamedResourceSpec = &swarm.NamedGenericResource{
Kind: r.NamedResourceSpec.Kind,
Value: r.NamedResourceSpec.Value,
}
}
generic = append(generic, current)
}
return generic
}
func buildGenericResourceMap(genericRes []swarm.GenericResource) (map[string]swarm.GenericResource, error) {
m := make(map[string]swarm.GenericResource)

View File

@ -0,0 +1,48 @@
package genericresource
import (
api "github.com/moby/moby/api/types/swarm"
)
// NewSet creates a set object
func NewSet(key string, vals ...string) []api.GenericResource {
rs := make([]api.GenericResource, 0, len(vals))
for _, v := range vals {
rs = append(rs, NewString(key, v))
}
return rs
}
// NewString creates a String resource
func NewString(kind, value string) api.GenericResource {
return api.GenericResource{
NamedResourceSpec: &api.NamedGenericResource{
Kind: kind,
Value: value,
},
}
}
// NewDiscrete creates a Discrete resource
func NewDiscrete(key string, val int64) api.GenericResource {
return api.GenericResource{
DiscreteResourceSpec: &api.DiscreteGenericResource{
Kind: key,
Value: val,
},
}
}
// GetResource returns resources from the "resources" parameter matching the kind key
func GetResource(kind string, resources []api.GenericResource) []api.GenericResource {
var res []api.GenericResource
for _, r := range resources {
switch {
case r.DiscreteResourceSpec != nil && r.DiscreteResourceSpec.Kind == kind:
res = append(res, r)
case r.NamedResourceSpec != nil && r.NamedResourceSpec.Kind == kind:
res = append(res, r)
}
}
return res
}

View File

@ -1,3 +1,10 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.24
// Package genericresource is a local fork of SwarmKit's [genericresource] package,
// without protobuf dependencies.
//
// [genericresource]: https://github.com/moby/swarmkit/blob/v2.1.1/api/genericresource/parse.go
package genericresource
import (
@ -6,10 +13,10 @@ import (
"strconv"
"strings"
"github.com/moby/swarmkit/v2/api"
api "github.com/moby/moby/api/types/swarm"
)
func newParseError(format string, args ...interface{}) error {
func newParseError(format string, args ...any) error {
return fmt.Errorf("could not parse GenericResource: "+format, args...)
}
@ -32,15 +39,14 @@ func allNamedResources(res []string) bool {
}
// ParseCmd parses the Generic Resource command line argument
// and returns a list of *api.GenericResource
func ParseCmd(cmd string) ([]*api.GenericResource, error) {
// and returns a list of api.GenericResource
func ParseCmd(cmd string) ([]api.GenericResource, error) {
if strings.Contains(cmd, "\n") {
return nil, newParseError("unexpected '\\n' character")
}
r := csv.NewReader(strings.NewReader(cmd))
records, err := r.ReadAll()
if err != nil {
return nil, newParseError("%v", err)
}
@ -53,7 +59,7 @@ func ParseCmd(cmd string) ([]*api.GenericResource, error) {
}
// Parse parses a table of GenericResource resources
func Parse(cmds []string) ([]*api.GenericResource, error) {
func Parse(cmds []string) ([]api.GenericResource, error) {
tokens := make(map[string][]string)
for _, term := range cmds {
@ -69,7 +75,7 @@ func Parse(cmds []string) ([]*api.GenericResource, error) {
tokens[key] = append(tokens[key], val)
}
var rs []*api.GenericResource
var rs []api.GenericResource
for k, v := range tokens {
if u, ok := isDiscreteResource(v); ok {
if u < 0 {
@ -107,5 +113,4 @@ func isDiscreteResource(values []string) (int64, bool) {
}
return u, true
}

View File

@ -0,0 +1,65 @@
package genericresource
import (
"testing"
"gotest.tools/v3/assert"
)
func TestParseDiscrete(t *testing.T) {
res, err := ParseCmd("apple=3")
assert.NilError(t, err)
assert.Equal(t, len(res), 1)
apples := GetResource("apple", res)
assert.Equal(t, len(apples), 1)
if apples[0].DiscreteResourceSpec == nil {
t.Fatalf("expected discrete resource spec, got nil")
}
assert.Equal(t, apples[0].DiscreteResourceSpec.Value, int64(3))
_, err = ParseCmd("apple=3\napple=4")
assert.Assert(t, err != nil)
_, err = ParseCmd("apple=3,apple=4")
assert.Assert(t, err != nil)
_, err = ParseCmd("apple=-3")
assert.Assert(t, err != nil)
}
func TestParseStr(t *testing.T) {
res, err := ParseCmd("orange=red,orange=green,orange=blue")
assert.NilError(t, err)
assert.Equal(t, len(res), 3)
oranges := GetResource("orange", res)
assert.Equal(t, len(oranges), 3)
for _, k := range []string{"red", "green", "blue"} {
assert.Assert(t, HasResource(NewString("orange", k), oranges))
}
}
func TestParseDiscreteAndStr(t *testing.T) {
res, err := ParseCmd("orange=red,orange=green,orange=blue,apple=3")
assert.NilError(t, err)
assert.Equal(t, len(res), 4)
oranges := GetResource("orange", res)
assert.Equal(t, len(oranges), 3)
for _, k := range []string{"red", "green", "blue"} {
assert.Assert(t, HasResource(NewString("orange", k), oranges))
}
apples := GetResource("apple", res)
assert.Equal(t, len(apples), 1)
if apples[0].DiscreteResourceSpec == nil {
t.Fatalf("expected discrete resource spec, got nil")
}
assert.Equal(t, apples[0].DiscreteResourceSpec.Value, int64(3))
}
func TestParseMixedForSameKindFails(t *testing.T) {
_, err := ParseCmd("gpu=fast,gpu=slow,gpu=2")
assert.Assert(t, err != nil)
}

View File

@ -0,0 +1,29 @@
package genericresource
import (
api "github.com/moby/moby/api/types/swarm"
)
// HasResource checks if there is enough "res" in the "resources" argument
func HasResource(res api.GenericResource, resources []api.GenericResource) bool {
for _, r := range resources {
if equalResource(r, res) {
return true
}
}
return false
}
// equalResource matches the resource *type* (named vs discrete), and then kind+value.
func equalResource(a, b api.GenericResource) bool {
switch {
case a.NamedResourceSpec != nil && b.NamedResourceSpec != nil:
return a.NamedResourceSpec.Kind == b.NamedResourceSpec.Kind &&
a.NamedResourceSpec.Value == b.NamedResourceSpec.Value
case a.DiscreteResourceSpec != nil && b.DiscreteResourceSpec != nil:
return a.DiscreteResourceSpec.Kind == b.DiscreteResourceSpec.Kind &&
a.DiscreteResourceSpec.Value == b.DiscreteResourceSpec.Value
}
return false
}

View File

@ -1,111 +0,0 @@
package genericresource
import (
"github.com/moby/swarmkit/v2/api"
)
// NewSet creates a set object
func NewSet(key string, vals ...string) []*api.GenericResource {
rs := make([]*api.GenericResource, 0, len(vals))
for _, v := range vals {
rs = append(rs, NewString(key, v))
}
return rs
}
// NewString creates a String resource
func NewString(key, val string) *api.GenericResource {
return &api.GenericResource{
Resource: &api.GenericResource_NamedResourceSpec{
NamedResourceSpec: &api.NamedGenericResource{
Kind: key,
Value: val,
},
},
}
}
// NewDiscrete creates a Discrete resource
func NewDiscrete(key string, val int64) *api.GenericResource {
return &api.GenericResource{
Resource: &api.GenericResource_DiscreteResourceSpec{
DiscreteResourceSpec: &api.DiscreteGenericResource{
Kind: key,
Value: val,
},
},
}
}
// GetResource returns resources from the "resources" parameter matching the kind key
func GetResource(kind string, resources []*api.GenericResource) []*api.GenericResource {
var res []*api.GenericResource
for _, r := range resources {
if Kind(r) != kind {
continue
}
res = append(res, r)
}
return res
}
// ConsumeNodeResources removes "res" from nodeAvailableResources
func ConsumeNodeResources(nodeAvailableResources *[]*api.GenericResource, res []*api.GenericResource) {
if nodeAvailableResources == nil {
return
}
w := 0
loop:
for _, na := range *nodeAvailableResources {
for _, r := range res {
if Kind(na) != Kind(r) {
continue
}
if remove(na, r) {
continue loop
}
// If this wasn't the right element then
// we need to continue
}
(*nodeAvailableResources)[w] = na
w++
}
*nodeAvailableResources = (*nodeAvailableResources)[:w]
}
// Returns true if the element is to be removed from the list
func remove(na, r *api.GenericResource) bool {
switch tr := r.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
if na.GetDiscreteResourceSpec() == nil {
return false // Type change, ignore
}
na.GetDiscreteResourceSpec().Value -= tr.DiscreteResourceSpec.Value
if na.GetDiscreteResourceSpec().Value <= 0 {
return true
}
case *api.GenericResource_NamedResourceSpec:
if na.GetNamedResourceSpec() == nil {
return false // Type change, ignore
}
if tr.NamedResourceSpec.Value != na.GetNamedResourceSpec().Value {
return false // not the right item, ignore
}
return true
}
return false
}

View File

@ -1,203 +0,0 @@
package genericresource
import (
"fmt"
"github.com/moby/swarmkit/v2/api"
)
// Claim assigns GenericResources to a task by taking them from the
// node's GenericResource list and storing them in the task's available list
func Claim(nodeAvailableResources, taskAssigned *[]*api.GenericResource,
taskReservations []*api.GenericResource) error {
var resSelected []*api.GenericResource
for _, res := range taskReservations {
tr := res.GetDiscreteResourceSpec()
if tr == nil {
return fmt.Errorf("task should only hold Discrete type")
}
// Select the resources
nrs, err := selectNodeResources(*nodeAvailableResources, tr)
if err != nil {
return err
}
resSelected = append(resSelected, nrs...)
}
ClaimResources(nodeAvailableResources, taskAssigned, resSelected)
return nil
}
// ClaimResources adds the specified resources to the task's list
// and removes them from the node's generic resource list
func ClaimResources(nodeAvailableResources, taskAssigned *[]*api.GenericResource,
resSelected []*api.GenericResource) {
*taskAssigned = append(*taskAssigned, resSelected...)
ConsumeNodeResources(nodeAvailableResources, resSelected)
}
func selectNodeResources(nodeRes []*api.GenericResource,
tr *api.DiscreteGenericResource) ([]*api.GenericResource, error) {
var nrs []*api.GenericResource
for _, res := range nodeRes {
if Kind(res) != tr.Kind {
continue
}
switch nr := res.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
if nr.DiscreteResourceSpec.Value >= tr.Value && tr.Value != 0 {
nrs = append(nrs, NewDiscrete(tr.Kind, tr.Value))
}
return nrs, nil
case *api.GenericResource_NamedResourceSpec:
nrs = append(nrs, res.Copy())
if int64(len(nrs)) == tr.Value {
return nrs, nil
}
}
}
if len(nrs) == 0 {
return nil, fmt.Errorf("not enough resources available for task reservations: %+v", tr)
}
return nrs, nil
}
// Reclaim adds the resources taken by the task to the node's store
func Reclaim(nodeAvailableResources *[]*api.GenericResource, taskAssigned, nodeRes []*api.GenericResource) error {
err := reclaimResources(nodeAvailableResources, taskAssigned)
if err != nil {
return err
}
sanitize(nodeRes, nodeAvailableResources)
return nil
}
func reclaimResources(nodeAvailableResources *[]*api.GenericResource, taskAssigned []*api.GenericResource) error {
// The node could have been updated
if nodeAvailableResources == nil {
return fmt.Errorf("node no longer has any resources")
}
for _, res := range taskAssigned {
switch tr := res.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
nrs := GetResource(tr.DiscreteResourceSpec.Kind, *nodeAvailableResources)
// If the resource went down to 0 it's no longer in the
// available list
if len(nrs) == 0 {
*nodeAvailableResources = append(*nodeAvailableResources, res.Copy())
}
if len(nrs) != 1 {
continue // Type change
}
nr := nrs[0].GetDiscreteResourceSpec()
if nr == nil {
continue // Type change
}
nr.Value += tr.DiscreteResourceSpec.Value
case *api.GenericResource_NamedResourceSpec:
*nodeAvailableResources = append(*nodeAvailableResources, res.Copy())
}
}
return nil
}
// sanitize checks that nodeAvailableResources does not add resources unknown
// to the nodeSpec (nodeRes) or goes over the integer bound specified
// by the spec.
// Note this is because the user is able to update a node's resources
func sanitize(nodeRes []*api.GenericResource, nodeAvailableResources *[]*api.GenericResource) {
// - We add the sanitized resources at the end, after
// having removed the elements from the list
// - When a set changes to a Discrete we also need
// to make sure that we don't add the Discrete multiple
// time hence, the need of a map to remember that
var sanitized []*api.GenericResource
kindSanitized := make(map[string]struct{})
w := 0
for _, na := range *nodeAvailableResources {
ok, nrs := sanitizeResource(nodeRes, na)
if !ok {
if _, ok = kindSanitized[Kind(na)]; ok {
continue
}
kindSanitized[Kind(na)] = struct{}{}
sanitized = append(sanitized, nrs...)
continue
}
(*nodeAvailableResources)[w] = na
w++
}
*nodeAvailableResources = (*nodeAvailableResources)[:w]
*nodeAvailableResources = append(*nodeAvailableResources, sanitized...)
}
// Returns true if the element is in nodeRes and "sane"
// Returns false if the element isn't in nodeRes and "sane" and the element(s) that should be replacing it
func sanitizeResource(nodeRes []*api.GenericResource, res *api.GenericResource) (ok bool, nrs []*api.GenericResource) {
switch na := res.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
nrs := GetResource(na.DiscreteResourceSpec.Kind, nodeRes)
// Type change or removed: reset
if len(nrs) != 1 {
return false, nrs
}
// Type change: reset
nr := nrs[0].GetDiscreteResourceSpec()
if nr == nil {
return false, nrs
}
// Amount change: reset
if na.DiscreteResourceSpec.Value > nr.Value {
return false, nrs
}
case *api.GenericResource_NamedResourceSpec:
nrs := GetResource(na.NamedResourceSpec.Kind, nodeRes)
// Type change
if len(nrs) == 0 {
return false, nrs
}
for _, nr := range nrs {
// Type change: reset
if nr.GetDiscreteResourceSpec() != nil {
return false, nrs
}
if na.NamedResourceSpec.Value == nr.GetNamedResourceSpec().Value {
return true, nil
}
}
// Removed
return false, nil
}
return true, nil
}

View File

@ -1,54 +0,0 @@
package genericresource
import (
"strconv"
"strings"
"github.com/moby/swarmkit/v2/api"
)
func discreteToString(d *api.GenericResource_DiscreteResourceSpec) string {
return strconv.FormatInt(d.DiscreteResourceSpec.Value, 10)
}
// Kind returns the kind key as a string
func Kind(res *api.GenericResource) string {
switch r := res.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
return r.DiscreteResourceSpec.Kind
case *api.GenericResource_NamedResourceSpec:
return r.NamedResourceSpec.Kind
}
return ""
}
// Value returns the value key as a string
func Value(res *api.GenericResource) string {
switch res := res.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
return discreteToString(res)
case *api.GenericResource_NamedResourceSpec:
return res.NamedResourceSpec.Value
}
return ""
}
// EnvFormat returns the environment string version of the resource
func EnvFormat(res []*api.GenericResource, prefix string) []string {
envs := make(map[string][]string)
for _, v := range res {
key := Kind(v)
val := Value(v)
envs[key] = append(envs[key], val)
}
env := make([]string, 0, len(res))
for k, v := range envs {
k = strings.ToUpper(prefix + "_" + k)
env = append(env, k+"="+strings.Join(v, ","))
}
return env
}

View File

@ -1,85 +0,0 @@
package genericresource
import (
"fmt"
"github.com/moby/swarmkit/v2/api"
)
// ValidateTask validates that the task only uses integers
// for generic resources
func ValidateTask(resources *api.Resources) error {
for _, v := range resources.Generic {
if v.GetDiscreteResourceSpec() != nil {
continue
}
return fmt.Errorf("invalid argument for resource %s", Kind(v))
}
return nil
}
// HasEnough returns true if node can satisfy the task's GenericResource request
func HasEnough(nodeRes []*api.GenericResource, taskRes *api.GenericResource) (bool, error) {
t := taskRes.GetDiscreteResourceSpec()
if t == nil {
return false, fmt.Errorf("task should only hold Discrete type")
}
if nodeRes == nil {
return false, nil
}
nrs := GetResource(t.Kind, nodeRes)
if len(nrs) == 0 {
return false, nil
}
switch nr := nrs[0].Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
if t.Value > nr.DiscreteResourceSpec.Value {
return false, nil
}
case *api.GenericResource_NamedResourceSpec:
if t.Value > int64(len(nrs)) {
return false, nil
}
}
return true, nil
}
// HasResource checks if there is enough "res" in the "resources" argument
func HasResource(res *api.GenericResource, resources []*api.GenericResource) bool {
for _, r := range resources {
if Kind(res) != Kind(r) {
continue
}
switch rtype := r.Resource.(type) {
case *api.GenericResource_DiscreteResourceSpec:
if res.GetDiscreteResourceSpec() == nil {
return false
}
if res.GetDiscreteResourceSpec().Value > rtype.DiscreteResourceSpec.Value {
return false
}
return true
case *api.GenericResource_NamedResourceSpec:
if res.GetNamedResourceSpec() == nil {
return false
}
if res.GetNamedResourceSpec().Value != rtype.NamedResourceSpec.Value {
continue
}
return true
}
}
return false
}

1
vendor/modules.txt vendored
View File

@ -214,7 +214,6 @@ github.com/moby/patternmatcher/ignorefile
github.com/moby/swarmkit/v2/api
github.com/moby/swarmkit/v2/api/deepcopy
github.com/moby/swarmkit/v2/api/defaults
github.com/moby/swarmkit/v2/api/genericresource
github.com/moby/swarmkit/v2/manager/raftselector
github.com/moby/swarmkit/v2/protobuf/plugin
# github.com/moby/sys/atomicwriter v0.1.0