diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 6201dc40..e464c269 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -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 @@ -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 @@ -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" \ diff --git a/config/hardware.go b/config/hardware.go new file mode 100644 index 00000000..66137fde --- /dev/null +++ b/config/hardware.go @@ -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"` +} diff --git a/config/hardware_schema_test.go b/config/hardware_schema_test.go new file mode 100644 index 00000000..f9b95e66 --- /dev/null +++ b/config/hardware_schema_test.go @@ -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) + } + } +} diff --git a/config/schema.go b/config/schema.go index 37f01fd3..d30c8dc7 100644 --- a/config/schema.go +++ b/config/schema.go @@ -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"}, @@ -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"}, diff --git a/config/types.go b/config/types.go index 43692619..61aac790 100644 --- a/config/types.go +++ b/config/types.go @@ -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"` @@ -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"` diff --git a/config/validation.go b/config/validation.go index 2bf51e5a..ec45a027 100644 --- a/config/validation.go +++ b/config/validation.go @@ -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: @@ -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 diff --git a/convert/convert.go b/convert/convert.go index b266061a..8a203acf 100644 --- a/convert/convert.go +++ b/convert/convert.go @@ -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, @@ -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)), diff --git a/convert/hardware.go b/convert/hardware.go new file mode 100644 index 00000000..6fc9742f --- /dev/null +++ b/convert/hardware.go @@ -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 +} diff --git a/convert/hardware_test.go b/convert/hardware_test.go new file mode 100644 index 00000000..76e7d4d6 --- /dev/null +++ b/convert/hardware_test.go @@ -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) + } + } +} diff --git a/docs/releases/compose-executor-0.14.35.md b/docs/releases/compose-executor-0.14.35.md new file mode 100644 index 00000000..73ae68c2 --- /dev/null +++ b/docs/releases/compose-executor-0.14.35.md @@ -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. diff --git a/go.mod b/go.mod index 47ebcc96..ade2cd6a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 7f0d2b4f..8c8bd3bf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/rancherclient/v2/device_request.go b/internal/rancherclient/v2/device_request.go new file mode 100644 index 00000000..df2a1c66 --- /dev/null +++ b/internal/rancherclient/v2/device_request.go @@ -0,0 +1,10 @@ +package client + +// DeviceRequest is the LaunchConfig hardware contract, not a scheduler reservation. +type DeviceRequest struct { + Driver string `json:"driver,omitempty" yaml:"driver,omitempty"` + Count int `json:"count,omitempty" yaml:"count,omitempty"` + DeviceIDs []string `json:"deviceIds,omitempty" yaml:"device_ids,omitempty"` + Capabilities [][]string `json:"capabilities" yaml:"capabilities"` + Options map[string]string `json:"options,omitempty" yaml:"options,omitempty"` +} diff --git a/internal/rancherclient/v2/generated_container.go b/internal/rancherclient/v2/generated_container.go index 4cdf2a4e..a05cf9f1 100644 --- a/internal/rancherclient/v2/generated_container.go +++ b/internal/rancherclient/v2/generated_container.go @@ -65,6 +65,10 @@ type Container struct { Devices []string `json:"devices,omitempty" yaml:"devices,omitempty"` + Runtime string `json:"runtime,omitempty" yaml:"runtime,omitempty"` + + DeviceRequests []DeviceRequest `json:"deviceRequests,omitempty" yaml:"device_requests,omitempty"` + DiskQuota int64 `json:"diskQuota,omitempty" yaml:"disk_quota,omitempty"` Dns []string `json:"dns,omitempty" yaml:"dns,omitempty"` diff --git a/internal/rancherclient/v2/generated_launch_config.go b/internal/rancherclient/v2/generated_launch_config.go index adbb6b1b..33ce5d82 100644 --- a/internal/rancherclient/v2/generated_launch_config.go +++ b/internal/rancherclient/v2/generated_launch_config.go @@ -67,6 +67,10 @@ type LaunchConfig struct { Devices []string `json:"devices,omitempty" yaml:"devices,omitempty"` + Runtime string `json:"runtime,omitempty" yaml:"runtime,omitempty"` + + DeviceRequests []DeviceRequest `json:"deviceRequests,omitempty" yaml:"device_requests,omitempty"` + DiskQuota int64 `json:"diskQuota,omitempty" yaml:"disk_quota,omitempty"` Disks []VirtualMachineDisk `json:"disks,omitempty" yaml:"disks,omitempty"` diff --git a/internal/rancherclient/v2/generated_secondary_launch_config.go b/internal/rancherclient/v2/generated_secondary_launch_config.go index b5653fa6..46134201 100644 --- a/internal/rancherclient/v2/generated_secondary_launch_config.go +++ b/internal/rancherclient/v2/generated_secondary_launch_config.go @@ -67,6 +67,10 @@ type SecondaryLaunchConfig struct { Devices []string `json:"devices,omitempty" yaml:"devices,omitempty"` + Runtime string `json:"runtime,omitempty" yaml:"runtime,omitempty"` + + DeviceRequests []DeviceRequest `json:"deviceRequests,omitempty" yaml:"device_requests,omitempty"` + DiskQuota int64 `json:"diskQuota,omitempty" yaml:"disk_quota,omitempty"` Disks []VirtualMachineDisk `json:"disks,omitempty" yaml:"disks,omitempty"` diff --git a/platformapi/convert/convert.go b/platformapi/convert/convert.go index 7b400c9f..47f91e88 100644 --- a/platformapi/convert/convert.go +++ b/platformapi/convert/convert.go @@ -59,7 +59,7 @@ func CreateLaunchConfig(name string, serviceConfig *config.ServiceConfig, c *cli dockerContainer.HostConfig.NetworkMode = container.NetworkMode("") dockerContainer.Name = "/" + name - if c.Post(scriptsUrl, dockerContainer, &result); err != nil { + if err = c.Post(scriptsUrl, dockerContainer, &result); err != nil { return result, err } @@ -79,6 +79,9 @@ func CreateLaunchConfig(name string, serviceConfig *config.ServiceConfig, c *cli if result.Labels == nil { result.Labels = map[string]interface{}{} } + if result.LogConfig == nil { + result.LogConfig = &client.LogConfig{} + } if result.LogConfig.Config == nil { result.LogConfig.Config = map[string]interface{}{} } diff --git a/platformapi/convert/hardware_test.go b/platformapi/convert/hardware_test.go new file mode 100644 index 00000000..a89da473 --- /dev/null +++ b/platformapi/convert/hardware_test.go @@ -0,0 +1,65 @@ +package convert + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/PastureStack/compose-cli/config" + client "github.com/PastureStack/compose-cli/internal/rancherclient/v2" + "github.com/PastureStack/compose-cli/project" + yaml "gopkg.in/yaml.v3" +) + +func TestHardwareTransformHTTPContract(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusUnprocessableEntity} { + t.Run(http.StatusText(status), func(t *testing.T) { + var inspected ContainerInspect + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v2-beta/scripts/transform" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&inspected); err != nil { + t.Error(err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status != http.StatusOK { + _, _ = w.Write([]byte(`{"type":"error","message":"invalid hardware"}`)) + return + } + _, _ = w.Write([]byte(`{"runtime":"nvidia","shmSize":2147483648,"deviceRequests":[{"driver":"nvidia","deviceIds":["GPU-one"],"capabilities":[["gpu"]],"options":{"mode":"test"}}],"groupAdd":["993"],"ports":["127.0.0.1:5903:5901/tcp"],"restartPolicy":{"name":"unless-stopped"}}`)) + })) + defer server.Close() + c := &client.RancherClient{RancherBaseClient: &client.RancherBaseClientImpl{ + Opts: &client.ClientOpts{Url: server.URL, Timeout: time.Second}, + Schemas: &client.Schemas{Collection: client.Collection{Links: map[string]string{"self": server.URL + "/v2-beta/schemas"}}}, + }} + var service config.ServiceConfig + if err := yaml.Unmarshal([]byte("image: example\nruntime: nvidia\nshm_size: 2g\nrestart: unless-stopped\ngroup_add: ['993']\nports: ['127.0.0.1:5903:5901']\ngpus:\n - driver: nvidia\n device_ids: [GPU-one]\n options: {mode: test}\n"), &service); err != nil { + t.Fatal(err) + } + result, err := CreateLaunchConfig("gpu", &service, c, project.Context{}) + if status != http.StatusOK { + if err == nil { + t.Fatal("transform rejection was silently discarded") + } + return + } + if err != nil { + t.Fatal(err) + } + if inspected.HostConfig.Runtime != "nvidia" || inspected.HostConfig.ShmSize != 2147483648 || len(inspected.HostConfig.DeviceRequests) != 1 || inspected.HostConfig.DeviceRequests[0].DeviceIDs[0] != "GPU-one" { + t.Fatalf("lost outgoing hardware: %+v", inspected.HostConfig) + } + if result.Runtime != "nvidia" || result.ShmSize != 2147483648 || len(result.DeviceRequests) != 1 || result.DeviceRequests[0].DeviceIDs[0] != "GPU-one" || result.DeviceRequests[0].Options["mode"] != "test" { + t.Fatalf("lost incoming hardware: %+v", result) + } + if result.Ports[0] != "127.0.0.1:5903:5901/tcp" || inspected.HostConfig.RestartPolicy.Name != "unless-stopped" { + t.Fatal("unrelated launch fields changed") + } + }) + } +} diff --git a/scripts/config_schema_v1.json b/scripts/config_schema_v1.json index 242fb5a2..77a725dd 100644 --- a/scripts/config_schema_v1.json +++ b/scripts/config_schema_v1.json @@ -22,6 +22,10 @@ "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"}, diff --git a/scripts/config_schema_v2.0.json b/scripts/config_schema_v2.0.json index b3771d8d..2e9431f5 100644 --- a/scripts/config_schema_v2.0.json +++ b/scripts/config_schema_v2.0.json @@ -35,6 +35,10 @@ ] }, "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"}, diff --git a/security/openvex.json b/security/openvex.json index 016edd07..77fa8afd 100644 --- a/security/openvex.json +++ b/security/openvex.json @@ -2,8 +2,8 @@ "@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://github.com/PastureStack/compose-cli/security/openvex/2026-08-25", "author": "PastureStack contributors", - "timestamp": "2026-08-25T22:05:55+08:00", - "version": 1, + "timestamp": "2026-09-07T22:15:00+08:00", + "version": 2, "statements": [ { "vulnerability": { @@ -11,12 +11,12 @@ }, "products": [ { - "@id": "pkg:golang/golang.org/x/crypto@v0.55.0" + "@id": "pkg:golang/golang.org/x/crypto@v0.56.0" } ], "status": "not_affected", "justification": "vulnerable_code_not_present", - "impact_statement": "GO-2026-5932 is limited to the discontinued golang.org/x/crypto/openpgp package. This repository uses maintained bcrypt and scrypt packages through Sprig; the complete vendored package graph and shipped binary contain no openpgp package. Govulncheck reports zero reachable and zero imported-package vulnerabilities." + "impact_statement": "GO-2026-5932 is limited to the discontinued golang.org/x/crypto/openpgp package. This repository uses maintained bcrypt and scrypt packages through Sprig. The complete package graph and vendored sources contain no openpgp package, enforced by the source gate. The v0.56.0 dependency update also resolves the module-level SSH advisories CVE-2026-56855 and CVE-2026-78662 without adding SSH or OpenPGP imports." } ] } diff --git a/vendor/modules.txt b/vendor/modules.txt index 973f2e22..4d95740a 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -250,8 +250,8 @@ github.com/xeipuuv/gojsonschema # go.yaml.in/yaml/v3 v3.0.5 ## explicit; go 1.16 go.yaml.in/yaml/v3 -# golang.org/x/crypto v0.55.0 -## explicit; go 1.25.0 +# golang.org/x/crypto v0.56.0 +## explicit; go 1.26.0 golang.org/x/crypto/bcrypt golang.org/x/crypto/blowfish golang.org/x/crypto/pbkdf2