Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/security-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
env:
DAPPER_IMAGE: pasturestack/compose-cli-dapper:${{ github.sha }}
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
VERSION_OVERRIDE: 0.14.33
VERSION_OVERRIDE: 0.14.35
PLATFORM_COMPAT_JAR_URL: https://github.com/PastureStack/orchestration-engine/releases/download/v0.183.281/orchestration-engine-0.183.281.jar
PLATFORM_COMPAT_JAR_SHA256: da2a8a51562ed16e296f7e29e99482bb44042ff0834cca679bbe01d951ba1682

Expand Down Expand Up @@ -69,7 +69,7 @@ jobs:
}

run_ci
artifact="dist/artifacts/compose-executor-0.14.33-linux-amd64.gz"
artifact="dist/artifacts/compose-executor-0.14.35-linux-amd64.gz"
test -s "$artifact"
cp "$artifact" /tmp/compose-executor-first.gz

Expand All @@ -81,7 +81,7 @@ jobs:
mkdir -p evidence/product
gzip -cd "$artifact" > evidence/product/compose-executor
chmod +x evidence/product/compose-executor
evidence/product/compose-executor --version | grep -F '0.14.33' >/dev/null
evidence/product/compose-executor --version | grep -F '0.14.35' >/dev/null
sha256sum "$artifact" > evidence/compose-executor.gz.sha256
docker run --rm --entrypoint go \
--volume "$PWD:/work:ro" \
Expand Down
72 changes: 72 additions & 0 deletions config/hardware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package config

import (
"fmt"
"strconv"
)

type DeviceCount int

func (c DeviceCount) MarshalYAML() (interface{}, error) {
if c == -1 {
return "all", nil
}
if c < 1 {
return nil, fmt.Errorf("invalid GPU count")
}
return int(c), nil
}
func (c *DeviceCount) UnmarshalYAML(unmarshal func(interface{}) error) error {
var value string
if err := unmarshal(&value); err != nil {
return err
}
if value == "all" {
*c = -1
return nil
}
n, err := strconv.Atoi(value)
if err != nil || n < 1 {
return fmt.Errorf("GPU count must be a positive integer or all")
}
*c = DeviceCount(n)
return nil
}

type GPURequest struct {
Driver string `yaml:"driver,omitempty"`
Count *DeviceCount `yaml:"count,omitempty"`
DeviceIDs []string `yaml:"device_ids,omitempty"`
Capabilities []string `yaml:"capabilities,omitempty"`
Options map[string]string `yaml:"options,omitempty"`
}
type GPURequests []GPURequest

func (g *GPURequests) UnmarshalYAML(unmarshal func(interface{}) error) error {
var scalar string
if unmarshal(&scalar) == nil {
if scalar != "all" {
return fmt.Errorf("gpus scalar must be all")
}
count := DeviceCount(-1)
*g = GPURequests{{Count: &count}}
return nil
}
type plain GPURequests
var requests plain
if err := unmarshal(&requests); err != nil {
return err
}
*g = GPURequests(requests)
return nil
}

// Only the device reservation portion of deploy is implemented. The schema
// rejects unsupported deploy directives instead of silently ignoring them.
type HardwareDeployment struct {
Resources struct {
Reservations struct {
Devices []GPURequest `yaml:"devices,omitempty"`
} `yaml:"reservations,omitempty"`
} `yaml:"resources,omitempty"`
}
37 changes: 37 additions & 0 deletions config/hardware_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package config_test

import (
"github.com/PastureStack/compose-cli/lookup"
"strings"
"testing"
)

func TestHardwareSchemaAcceptsStandardGpuForms(t *testing.T) {
for _, gpu := range []string{"gpus: all", "gpus:\n - driver: nvidia\n count: 2", "deploy:\n resources:\n reservations:\n devices:\n - driver: nvidia\n device_ids: [GPU-one]\n capabilities: [gpu]"} {
settings := "image: example\nruntime: nvidia\nshm_size: 2g\npids_limit: 512\n" + gpu
for _, header := range []string{"app:\n", "version: '2'\nservices:\n app:\n"} {
indent := " "
if strings.HasPrefix(header, "version") {
indent = " "
}
compose := header + indent + strings.ReplaceAll(settings, "\n", "\n"+indent) + "\n"
parsed, err := mergeWithResourceLookup([]byte(compose), "", lookup.NewFileResourceLookup())
if err != nil {
t.Fatalf("%s: %v", compose, err)
}
app := parsed.Services["app"]
if app.Runtime != "nvidia" || app.ShmSize != 2147483648 || app.PidsLimit == nil || *app.PidsLimit != 512 {
t.Fatalf("lost options: %+v", app)
}
}
}
}

func TestHardwareSchemaRejectsUnimplementedDeploySettings(t *testing.T) {
for _, extra := range []string{"deploy:\n replicas: 2", "deploy:\n resources:\n reservations:\n devices:\n - count: all", "gpus: 1", "gpus:\n - count: false", "gpus:\n - count: {}", "gpus:\n - device_ids: [1]"} {
_, err := mergeWithResourceLookup([]byte("version: '2'\nservices:\n app:\n image: example\n "+extra+"\n"), "", lookup.NewFileResourceLookup())
if err == nil {
t.Fatalf("silently accepted %s", extra)
}
}
}
8 changes: 8 additions & 0 deletions config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ var schemaDataV1 = `{
"blkio_weight_device": {"$ref": "#/definitions/list_of_strings"},
"build": {"type": "string"},
"cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"runtime": {"type": "string"},
"gpus": {"oneOf":[{"enum":["all"]},{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"properties":{"driver":{"type":"string"},"count":{"oneOf":[{"type":"integer","minimum":1},{"enum":["all"]}]},"device_ids":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true,"minItems":1},"capabilities":{"type":"array","items":{"type":"string","minLength":1},"minItems":1},"options":{"type":"object","additionalProperties":{"type":"string"}}}}}]},
"deploy": {"type":"object","additionalProperties":false,"required":["resources"],"properties":{"resources":{"type":"object","additionalProperties":false,"required":["reservations"],"properties":{"reservations":{"type":"object","additionalProperties":false,"required":["devices"],"properties":{"devices":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"properties":{"driver":{"type":"string"},"count":{"oneOf":[{"type":"integer","minimum":1},{"enum":["all"]}]},"device_ids":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true,"minItems":1},"capabilities":{"type":"array","items":{"type":"string","minLength":1},"minItems":1},"options":{"type":"object","additionalProperties":{"type":"string"}}},"required":["capabilities"]}}}}}}}},
"pids_limit": {"type": "integer", "minimum": -1, "not": {"enum": [0]}},
"cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"certs": {"$ref": "#/definitions/list_of_strings"},
"cgroup_parent": {"type": "string"},
Expand Down Expand Up @@ -272,6 +276,10 @@ var servicesSchemaDataV2 = `{
]
},
"cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"runtime": {"type": "string"},
"gpus": {"oneOf":[{"enum":["all"]},{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"properties":{"driver":{"type":"string"},"count":{"oneOf":[{"type":"integer","minimum":1},{"enum":["all"]}]},"device_ids":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true,"minItems":1},"capabilities":{"type":"array","items":{"type":"string","minLength":1},"minItems":1},"options":{"type":"object","additionalProperties":{"type":"string"}}}}}]},
"deploy": {"type":"object","additionalProperties":false,"required":["resources"],"properties":{"resources":{"type":"object","additionalProperties":false,"required":["reservations"],"properties":{"reservations":{"type":"object","additionalProperties":false,"required":["devices"],"properties":{"devices":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"properties":{"driver":{"type":"string"},"count":{"oneOf":[{"type":"integer","minimum":1},{"enum":["all"]}]},"device_ids":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true,"minItems":1},"capabilities":{"type":"array","items":{"type":"string","minLength":1},"minItems":1},"options":{"type":"object","additionalProperties":{"type":"string"}}},"required":["capabilities"]}}}}}}}},
"pids_limit": {"type": "integer", "minimum": -1, "not": {"enum": [0]}},
"cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"certs": {"$ref": "#/definitions/list_of_strings"},
"cgroup_parent": {"type": "string"},
Expand Down
8 changes: 8 additions & 0 deletions config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ type ResourceLookup interface {

// ServiceConfigV1 holds version 1 Compose service configuration.
type ServiceConfigV1 struct {
Runtime string `yaml:"runtime,omitempty"`
GPUs GPURequests `yaml:"gpus,omitempty"`
Deploy *HardwareDeployment `yaml:"deploy,omitempty"`
PidsLimit *int64 `yaml:"pids_limit,omitempty"`
BlkioWeight yaml.StringorInt `yaml:"blkio_weight,omitempty"`
BlkioWeightDevice []string `yaml:"blkio_weight_device,omitempty"`
Build string `yaml:"build,omitempty"`
Expand Down Expand Up @@ -125,6 +129,10 @@ type Log struct {

// ServiceConfig holds version 2 Compose service configuration.
type ServiceConfig struct {
Runtime string `yaml:"runtime,omitempty"`
GPUs GPURequests `yaml:"gpus,omitempty"`
Deploy *HardwareDeployment `yaml:"deploy,omitempty"`
PidsLimit *int64 `yaml:"pids_limit,omitempty"`
BlkioWeight yaml.StringorInt `yaml:"blkio_weight,omitempty"`
BlkioWeightDevice []string `yaml:"blkio_weight_device,omitempty"`
Build yaml.Build `yaml:"build,omitempty"`
Expand Down
12 changes: 10 additions & 2 deletions config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func getValue(val interface{}, context string) string {
case string:
return typedVal
case []interface{}:
if index, err := strconv.Atoi(k); err == nil {
if index, err := strconv.Atoi(k); err == nil && index >= 0 && index < len(typedVal) {
val = typedVal[index]
}
case RawServiceMap:
Expand Down Expand Up @@ -255,8 +255,16 @@ func generateErrorMessages(serviceMap RawServiceMap, schema map[string]interface

switch err.Type() {
case "additional_property_not_allowed":
validationErrors = append(validationErrors, unsupportedConfigMessage(key, result.Errors()[i+1]))
property, _ := err.Details()["property"].(string)
if property == "" {
property = key
}
validationErrors = append(validationErrors, unsupportedConfigMessage(property, err))
case "number_one_of":
if i+1 >= len(result.Errors()) {
validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s': %s", serviceName, key, err.Description()))
continue
}
validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' %s", serviceName, key, oneOfMessage(serviceMap, schema, err, result.Errors()[i+1])))

// Next error handled in oneOfMessage, skip over it
Expand Down
7 changes: 7 additions & 0 deletions convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,13 @@ func Convert(c *config.ServiceConfig, ctx project.Context) (*DockerConfig, *cont
return nil, nil, err
}

deviceRequests, err := hardwareRequests(c)
if err != nil {
return nil, nil, err
}
resources := container.Resources{
DeviceRequests: deviceRequests,
PidsLimit: c.PidsLimit,
BlkioWeight: uint16(c.BlkioWeight),
BlkioWeightDevice: blkioWeightDevices,
CgroupParent: c.CgroupParent,
Expand All @@ -298,6 +304,7 @@ func Convert(c *config.ServiceConfig, ctx project.Context) (*DockerConfig, *cont
}

hostConfig := &container.HostConfig{
Runtime: c.Runtime,
VolumesFrom: volumesFrom,
CapAdd: strslice.StrSlice(utils.CopySlice(c.CapAdd)),
CapDrop: strslice.StrSlice(utils.CopySlice(c.CapDrop)),
Expand Down
72 changes: 72 additions & 0 deletions convert/hardware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package convert

import (
"fmt"
"strings"

"github.com/PastureStack/compose-cli/config"
"github.com/moby/moby/api/types/container"
)

func hardwareRequests(c *config.ServiceConfig) ([]container.DeviceRequest, error) {
if c.ShmSize < 0 || (c.ShmSize > 0 && c.Ipc != "" && c.Ipc != "private" && c.Ipc != "shareable") {
return nil, fmt.Errorf("shm_size must be non-negative and requires private or shareable IPC")
}
if strings.Trim(c.Runtime, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") != "" {
return nil, fmt.Errorf("runtime must be a registered runtime name")
}
if c.PidsLimit != nil && *c.PidsLimit < -1 {
return nil, fmt.Errorf("pids_limit must be non-negative or -1")
}
requests := []container.DeviceRequest{}
items := []config.GPURequest(c.GPUs)
if c.Deploy != nil {
if len(items) > 0 && len(c.Deploy.Resources.Reservations.Devices) > 0 {
return nil, fmt.Errorf("use either gpus or deploy device reservations, not both")
}
items = append(items, c.Deploy.Resources.Reservations.Devices...)
}
for _, item := range items {
if item.Count != nil && len(item.DeviceIDs) > 0 {
return nil, fmt.Errorf("GPU count and device_ids are mutually exclusive")
}
count := -1
if len(item.DeviceIDs) > 0 {
count = 0
} else if item.Count != nil {
count = int(*item.Count)
}
if count < -1 || (count == 0 && len(item.DeviceIDs) == 0) {
return nil, fmt.Errorf("invalid GPU count")
}
caps := append([]string{}, item.Capabilities...)
if len(c.GPUs) > 0 {
gpu := false
for _, cap := range caps {
if cap == "gpu" {
gpu = true
}
}
if !gpu {
caps = append(caps, "gpu")
}
}
if len(caps) == 0 {
return nil, fmt.Errorf("device reservations require capabilities")
}
for _, cap := range caps {
if strings.TrimSpace(cap) == "" {
return nil, fmt.Errorf("capabilities cannot be blank")
}
}
seen := map[string]bool{}
for _, id := range item.DeviceIDs {
if strings.TrimSpace(id) == "" || seen[id] {
return nil, fmt.Errorf("device_ids must be unique and non-empty")
}
seen[id] = true
}
requests = append(requests, container.DeviceRequest{Driver: item.Driver, Count: count, DeviceIDs: item.DeviceIDs, Capabilities: [][]string{caps}, Options: item.Options})
}
return requests, nil
}
59 changes: 59 additions & 0 deletions convert/hardware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package convert

import (
"encoding/json"
"testing"

"github.com/PastureStack/compose-cli/config"
"github.com/PastureStack/compose-cli/utils"
"gopkg.in/yaml.v3"
)

func TestHardwareComposeRoundTrip(t *testing.T) {
for _, value := range []string{
"gpus: all\n",
"gpus:\n - driver: nvidia\n count: 2\n",
"deploy:\n resources:\n reservations:\n devices:\n - driver: nvidia\n device_ids: [GPU-example]\n capabilities: [gpu]\n",
} {
var before config.ServiceConfigV1
if err := yaml.Unmarshal([]byte("image: example\nruntime: nvidia\nshm_size: 2g\nports: ['127.0.0.1:5903:5901']\ngroup_add: ['993']\n"+value), &before); err != nil {
t.Fatal(err)
}
converted, err := config.ConvertServices(map[string]*config.ServiceConfigV1{"app": &before})
if err != nil {
t.Fatal(err)
}
request, err := hardwareRequests(converted["app"])
if err != nil || len(request) != 1 {
t.Fatalf("%+v %v", request, err)
}
var after config.ServiceConfig
if err := utils.Convert(converted["app"], &after); err != nil {
t.Fatal(err)
}
again, err := hardwareRequests(&after)
a, _ := json.Marshal(request)
b, _ := json.Marshal(again)
if err != nil || string(a) != string(b) || after.ShmSize != 2147483648 || after.Ports[0] != "127.0.0.1:5903:5901" || after.Runtime != "nvidia" || after.GroupAdd[0] != "993" {
t.Fatalf("roundtrip lost options: %s %s %v", a, b, err)
}
}
}

func TestHardwareComposeRejectsContradictions(t *testing.T) {
for _, value := range []string{
"gpus:\n - count: 1\n device_ids: [GPU-example]\n",
"deploy:\n resources:\n reservations:\n devices:\n - count: all\n",
"gpus: all\ndeploy:\n resources:\n reservations:\n devices:\n - count: all\n capabilities: [gpu]\n",
"shm_size: 2g\nipc: host\n", "pids_limit: -2\n", "runtime: 'runc;other'\n",
} {
var c config.ServiceConfig
err := yaml.Unmarshal([]byte(value), &c)
if err == nil {
_, err = hardwareRequests(&c)
}
if err == nil {
t.Errorf("accepted %s", value)
}
}
}
13 changes: 13 additions & 0 deletions docs/releases/compose-executor-0.14.35.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Compose Executor v0.14.35

Support runtime, PID limits and GPU requests in the existing v1/v2 Compose path.
Accept `gpus: all`, the GPU request list form, and
`deploy.resources.reservations.devices`; reject unrelated deploy fields rather
than silently discarding them. Count and device IDs are mutually exclusive.

Preserve shared memory, CPU limits, device mappings and supplementary groups
through the Docker HostConfig-to-LaunchConfig API conversion. Propagate API
validation errors instead of returning a success-shaped empty configuration.

These options require compatible orchestration-engine and node-agent releases.
They do not add full Compose deploy support or GPU exclusive scheduling.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
Expand Down
Loading