From aa7f108a4409ef4ae82b4889d83c7eb01ce5eef4 Mon Sep 17 00:00:00 2001 From: lekaf974 Date: Wed, 20 May 2026 21:20:42 -0400 Subject: [PATCH 1/5] feat: enhance support for shorthand string values in container files and volumes Signed-off-by: lekaf974 --- Makefile | 4 +- schema/files/score-v1b1.json | 46 ++++++++++++++------- schema/files/score-v1b1.json.for-validation | 20 ++++++++- schema/schema_test.go | 20 +++++++++ schema/validate_test.go | 8 +++- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index ef63843..2160191 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,8 @@ default: ## Generate types .PHONY: generate generate: -# Then the second modification is to remove the array version of the container files and volumes. Anything based on score-go will just handle the map type. - jq '. as $$a | ."$$defs".container.properties.files |= $$a."$$defs".container.properties.files.oneOf[1] | ."$$defs".container.properties.files.additionalProperties |= $$a."$$defs".container.properties.files.oneOf[1].additionalProperties.allOf[1] | ."$$defs".container.properties.volumes |= $$a."$$defs".container.properties.volumes.oneOf[1] | ."$$defs".container.properties.volumes.additionalProperties |= $$a."$$defs".container.properties.volumes.oneOf[1].additionalProperties.allOf[1] | del(."$$defs".containerFile.properties.target) | del(."$$defs".containerVolume.properties.target)' schema/files/score-v1b1.json > schema/files/score-v1b1.json.for-validation +# Then the second modification is to remove the array version of the container files and volumes while preserving shorthand string forms. + jq '. as $$a | ."$$defs".container.properties.files |= $$a."$$defs".container.properties.files.oneOf[1] | ."$$defs".container.properties.files.additionalProperties = {"oneOf":[{"$$ref":"#/$$defs/containerFile"},{"type":"string"}]} | ."$$defs".container.properties.volumes |= $$a."$$defs".container.properties.volumes.oneOf[1] | ."$$defs".container.properties.volumes.additionalProperties = {"oneOf":[{"$$ref":"#/$$defs/containerVolume"},{"type":"string"}]} | del(."$$defs".containerFile.properties.target) | del(."$$defs".containerVolume.properties.target)' schema/files/score-v1b1.json > schema/files/score-v1b1.json.for-validation # Unfortunately struct generators don't know how to handle mixed properties and additional properties so we have to strip these out before we generate the structs. # We still validate with the original specification though. jq 'walk(if type == "object" and .type == "object" and .additionalProperties == true and (.properties | type) == "object" then (del(.required) | del(.properties)) else . end)' schema/files/score-v1b1.json.for-validation > schema/files/score-v1b1.json.for-generation diff --git a/schema/files/score-v1b1.json b/schema/files/score-v1b1.json index 420f47d..802e6cb 100644 --- a/schema/files/score-v1b1.json +++ b/schema/files/score-v1b1.json @@ -310,7 +310,7 @@ } }, "files": { - "description": "The extra files to mount into the container. Described as a map of target paths to file details. The array form is deprecated.", + "description": "The extra files to mount into the container. Described as a map of target paths to file details. A shorthand string value is treated as the file content. The array form is deprecated.", "oneOf": [ { "type": "array", @@ -322,16 +322,23 @@ { "type": "object", "additionalProperties": { - "allOf": [ + "oneOf": [ { - "not": { - "type": "object", - "additionalProperties": true, - "required": ["target"] - } + "allOf": [ + { + "not": { + "type": "object", + "additionalProperties": true, + "required": ["target"] + } + }, + { + "$ref": "#/$defs/containerFile" + } + ] }, { - "$ref": "#/$defs/containerFile" + "type": "string" } ] } @@ -339,7 +346,7 @@ ] }, "volumes": { - "description": "The volumes to mount. Described as a map of target paths to volume details. The array form is deprecated.", + "description": "The volumes to mount. Described as a map of target paths to volume details. A shorthand string value is treated as the volume source. The array form is deprecated.", "oneOf": [ { "type": "array", @@ -351,16 +358,23 @@ { "type": "object", "additionalProperties": { - "allOf": [ + "oneOf": [ { - "not": { - "type": "object", - "additionalProperties": true, - "required": ["target"] - } + "allOf": [ + { + "not": { + "type": "object", + "additionalProperties": true, + "required": ["target"] + } + }, + { + "$ref": "#/$defs/containerVolume" + } + ] }, { - "$ref": "#/$defs/containerVolume" + "type": "string" } ] } diff --git a/schema/files/score-v1b1.json.for-validation b/schema/files/score-v1b1.json.for-validation index 3d4e9d5..88c0aef 100644 --- a/schema/files/score-v1b1.json.for-validation +++ b/schema/files/score-v1b1.json.for-validation @@ -299,15 +299,31 @@ } }, "files": { + "description": "The extra files to mount into the container. Described as a map of target paths to file details. A shorthand string value is treated as the file content.", "type": "object", "additionalProperties": { - "$ref": "#/$defs/containerFile" + "oneOf": [ + { + "$ref": "#/$defs/containerFile" + }, + { + "type": "string" + } + ] } }, "volumes": { + "description": "The volumes to mount. Described as a map of target paths to volume details. A shorthand string value is treated as the volume source.", "type": "object", "additionalProperties": { - "$ref": "#/$defs/containerVolume" + "oneOf": [ + { + "$ref": "#/$defs/containerVolume" + }, + { + "type": "string" + } + ] } }, "before": { diff --git a/schema/schema_test.go b/schema/schema_test.go index 5b72256..b3b2b16 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -503,6 +503,16 @@ func TestSchema(t *testing.T) { }(), Message: "", }, + { + Name: "containers.*.files supports shorthand string", + Src: func() map[string]interface{} { + src := newTestDocument() + var files = src["containers"].(map[string]interface{})["hello"].(map[string]interface{})["files"].(map[string]interface{}) + files["/etc/hello-world/config.yaml"] = "${resources.env.APP_CONFIG}" + return src + }(), + Message: "", + }, { Name: "containers.*.files.*.mode is not set", Src: func() map[string]interface{} { @@ -699,6 +709,16 @@ func TestSchema(t *testing.T) { }(), Message: "", }, + { + Name: "containers.*.volumes supports shorthand string", + Src: func() map[string]interface{} { + src := newTestDocument() + var volumes = src["containers"].(map[string]interface{})["hello"].(map[string]interface{})["volumes"].(map[string]interface{}) + volumes["/mnt/data"] = "${resources.data}" + return src + }(), + Message: "", + }, { Name: "containers.*.volumes.*.source is missing", Src: func() map[string]interface{} { diff --git a/schema/validate_test.go b/schema/validate_test.go index 975e0b7..49c1d34 100644 --- a/schema/validate_test.go +++ b/schema/validate_test.go @@ -52,11 +52,13 @@ containers: content: "${resources.env.APP_CONFIG}" /etc/hello-world/binary: content: "aGVsbG8=" + /etc/hello-world/config-shorthand: "${resources.env.APP_CONFIG}" volumes: /mnt/data: source: ${resources.data} path: sub/path readOnly: true + /volumes/data/short: ${resources.data} resources: limits: memory: "128Mi" @@ -211,14 +213,16 @@ func TestValidateJson(t *testing.T) { "/etc/hello-world/config.yaml": { "mode": "666", "content": "${resources.env.APP_CONFIG}" - } + }, + "/etc/hello-world/config-short.yaml": "${resources.env.APP_CONFIG}" }, "volumes": { "/mnt/data": { "source": "${resources.data}", "path": "sub/path", "readOnly": true - } + }, + "/volumes/data/short": "${resources.data}" }, "resources": { "limits": { From 589989b71254892ccc87c083cc89e78109424d64 Mon Sep 17 00:00:00 2001 From: lekaf974 Date: Mon, 25 May 2026 22:17:18 -0400 Subject: [PATCH 2/5] feat: update schema and types to add shorthand support for files and volumes Signed-off-by: lekaf974 --- schema/files/samples/score-full.yaml | 28 +- schema/files/score-v1b1.json | 14 +- schema/files/score-v1b1.json.for-validation | 18 +- types/types.gen.go | 322 ++++++++++---------- 4 files changed, 195 insertions(+), 187 deletions(-) diff --git a/schema/files/samples/score-full.yaml b/schema/files/samples/score-full.yaml index 0c312b3..5e70b1a 100644 --- a/schema/files/samples/score-full.yaml +++ b/schema/files/samples/score-full.yaml @@ -27,31 +27,33 @@ containers: variables: SOME_VAR: some content here files: - - target: /my/file + /my/file: mode: "0600" source: file.txt - - target: /my/other/file + /my/other/file: content: | some multiline content - - target: /my/other/binaryfile + /my/other/binaryfile: binaryContent: ADBgwpA= + /my/other/file-short: "some content here" volumes: - - source: volume-name - target: /mnt/something + /mnt/something: + source: volume-name path: /sub/path readOnly: false - - source: volume-two - target: /mnt/something-else + /mnt/something-else: + source: volume-two + /mnt/something-short: "volume-short" livenessProbe: httpGet: port: 8080 path: /livez exec: command: - - /bin/curl - - -f - - "http://localhost:8080/livez" + - /bin/curl + - -f + - "http://localhost:8080/livez" readinessProbe: httpGet: host: 127.0.0.1 @@ -59,8 +61,8 @@ containers: scheme: HTTP path: /readyz httpHeaders: - - name: SOME_HEADER - value: some-value-here + - name: SOME_HEADER + value: some-value-here container-two2: image: . resources: @@ -79,4 +81,4 @@ resources: type: Resource-Two resource-three: type: Type-Three - id: shared-type-three \ No newline at end of file + id: shared-type-three diff --git a/schema/files/score-v1b1.json b/schema/files/score-v1b1.json index 802e6cb..a42ccfa 100644 --- a/schema/files/score-v1b1.json +++ b/schema/files/score-v1b1.json @@ -310,7 +310,7 @@ } }, "files": { - "description": "The extra files to mount into the container. Described as a map of target paths to file details. A shorthand string value is treated as the file content. The array form is deprecated.", + "description": "The extra files to mount into the container. Described as a map of target paths to file details. The array form is deprecated.", "oneOf": [ { "type": "array", @@ -346,7 +346,7 @@ ] }, "volumes": { - "description": "The volumes to mount. Described as a map of target paths to volume details. A shorthand string value is treated as the volume source. The array form is deprecated.", + "description": "The volumes to mount. Described as a map of target paths to volume details. The array form is deprecated.", "oneOf": [ { "type": "array", @@ -434,7 +434,7 @@ }, "containerProbe": { "type": "object", - "description": "The probe may be defined as either http, command execution, or both. The execProbe should be preferred if the Score implementation supports both types.", + "description": "The probe definition. At least one of 'httpGet' or 'exec' must be specified. The execProbe should be preferred if the Score implementation supports both types.", "additionalProperties": false, "properties": { "httpGet": { @@ -443,7 +443,11 @@ "exec": { "$ref": "#/$defs/execProbe" } - } + }, + "anyOf": [ + {"required": ["httpGet"]}, + {"required": ["exec"]} + ] }, "execProbe": { "description": "An executable health probe.", @@ -519,4 +523,4 @@ } } } -} \ No newline at end of file +} diff --git a/schema/files/score-v1b1.json.for-validation b/schema/files/score-v1b1.json.for-validation index 88c0aef..f1fdcec 100644 --- a/schema/files/score-v1b1.json.for-validation +++ b/schema/files/score-v1b1.json.for-validation @@ -299,7 +299,6 @@ } }, "files": { - "description": "The extra files to mount into the container. Described as a map of target paths to file details. A shorthand string value is treated as the file content.", "type": "object", "additionalProperties": { "oneOf": [ @@ -313,7 +312,6 @@ } }, "volumes": { - "description": "The volumes to mount. Described as a map of target paths to volume details. A shorthand string value is treated as the volume source.", "type": "object", "additionalProperties": { "oneOf": [ @@ -381,7 +379,7 @@ }, "containerProbe": { "type": "object", - "description": "The probe may be defined as either http, command execution, or both. The execProbe should be preferred if the Score implementation supports both types.", + "description": "The probe definition. At least one of 'httpGet' or 'exec' must be specified. The execProbe should be preferred if the Score implementation supports both types.", "additionalProperties": false, "properties": { "httpGet": { @@ -390,7 +388,19 @@ "exec": { "$ref": "#/$defs/execProbe" } - } + }, + "anyOf": [ + { + "required": [ + "httpGet" + ] + }, + { + "required": [ + "exec" + ] + } + ] }, "execProbe": { "description": "An executable health probe.", diff --git a/types/types.gen.go b/types/types.gen.go index f907474..4e3ce30 100644 --- a/types/types.gen.go +++ b/types/types.gen.go @@ -11,10 +11,9 @@ type Container struct { // If specified, overrides the arguments passed to the container entrypoint. Args []string `json:"args,omitempty" yaml:"args,omitempty" mapstructure:"args,omitempty"` - // Defines before which other containers this container should be started. + // Containers which should be started before this container. Before ContainerBefore `json:"before,omitempty" yaml:"before,omitempty" mapstructure:"before,omitempty"` - // If specified, overrides the entrypoint defined in the container image. Command []string `json:"command,omitempty" yaml:"command,omitempty" mapstructure:"command,omitempty"` @@ -40,24 +39,12 @@ type Container struct { Volumes ContainerVolumes `json:"volumes,omitempty" yaml:"volumes,omitempty" mapstructure:"volumes,omitempty"` } -// ContainerBefore is a mapping of container names to their ready conditions, -// defining which containers must reach a certain state before this container starts. -type ContainerBefore map[string]ContainerBeforeEntry - -// ContainerBeforeEntry defines the ready condition for a container in the before mapping. -type ContainerBeforeEntry struct { - // The status of the container before the next containers are started. - Ready ContainerBeforeReady `json:"ready" yaml:"ready" mapstructure:"ready"` +// Containers which should be started before this container. +type ContainerBefore map[string]struct { + // The status of the container before the next container is started. + Ready Ready `json:"ready" yaml:"ready" mapstructure:"ready"` } -// ContainerBeforeReady represents the ready condition for ordered container start. -type ContainerBeforeReady string - -const ContainerBeforeReadyStarted ContainerBeforeReady = "started" -const ContainerBeforeReadyHealthy ContainerBeforeReady = "healthy" -const ContainerBeforeReadyComplete ContainerBeforeReady = "complete" - - // The details of a file to mount in the container. One of 'source', 'content', or // 'binaryContent' must be provided. type ContainerFile struct { @@ -79,9 +66,9 @@ type ContainerFile struct { Source *string `json:"source,omitempty" yaml:"source,omitempty" mapstructure:"source,omitempty"` } -type ContainerFiles map[string]ContainerFile +type ContainerFiles map[string]interface{} -// The probe may be defined as either http, command execution, or both. The +// The probe definition. At least one of 'httpGet' or 'exec' must be specified. The // execProbe should be preferred if the Score implementation supports both types. type ContainerProbe struct { // Exec corresponds to the JSON schema field "exec". @@ -114,7 +101,7 @@ type ContainerVolume struct { Source string `json:"source" yaml:"source" mapstructure:"source"` } -type ContainerVolumes map[string]ContainerVolume +type ContainerVolumes map[string]interface{} // An executable health probe. type ExecProbe struct { @@ -151,193 +138,225 @@ type HttpProbeHttpHeadersElem struct { type HttpProbeScheme string -const HttpProbeSchemeHTTP HttpProbeScheme = "HTTP" -const HttpProbeSchemeHTTPS HttpProbeScheme = "HTTPS" - -// The set of Resources associated with this Workload. -type Resource struct { - // An optional specialisation of the Resource type. - Class *string `json:"class,omitempty" yaml:"class,omitempty" mapstructure:"class,omitempty"` - - // An optional Resource identifier. The id may be up to 63 characters, including - // one or more labels of a-z, 0-9, '-' not starting or ending with '-' separated - // by '.'. When two resources share the same type, class, and id, they are - // considered the same resource when used across related Workloads. - Id *string `json:"id,omitempty" yaml:"id,omitempty" mapstructure:"id,omitempty"` - - // The metadata for the Resource. - Metadata ResourceMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty" mapstructure:"metadata,omitempty"` - - // Optional parameters used to provision the Resource in the environment. - Params ResourceParams `json:"params,omitempty" yaml:"params,omitempty" mapstructure:"params,omitempty"` - - // The Resource type. This should be a type supported by the Score implementations - // being used. - Type string `json:"type" yaml:"type" mapstructure:"type"` -} - -// The metadata for the Resource. -type ResourceMetadata map[string]interface{} - -// Optional parameters used to provision the Resource in the environment. -type ResourceParams map[string]interface{} - -// The compute and memory resource limits. -type ResourcesLimits struct { - // The CPU limit as whole or fractional CPUs. 'm' indicates milli-CPUs. For - // example 2 or 125m. - Cpu *string `json:"cpu,omitempty" yaml:"cpu,omitempty" mapstructure:"cpu,omitempty"` - - // The memory limit in bytes with optional unit specifier. For example 125M or - // 1Gi. - Memory *string `json:"memory,omitempty" yaml:"memory,omitempty" mapstructure:"memory,omitempty"` +// UnmarshalJSON implements json.Unmarshaler. +func (j *ContainerVolume) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["source"]; !ok || v == nil { + return fmt.Errorf("field source in ContainerVolume: required") + } + type Plain ContainerVolume + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + *j = ContainerVolume(plain) + return nil } -// The network port description. -type ServicePort struct { - // The public service port. - Port int `json:"port" yaml:"port" mapstructure:"port"` - - // The transport level protocol. Defaults to TCP. - Protocol *ServicePortProtocol `json:"protocol,omitempty" yaml:"protocol,omitempty" mapstructure:"protocol,omitempty"` - - // The internal service port. This will default to 'port' if not provided. - TargetPort *int `json:"targetPort,omitempty" yaml:"targetPort,omitempty" mapstructure:"targetPort,omitempty"` +// UnmarshalJSON implements json.Unmarshaler. +func (j *HttpProbeScheme) UnmarshalJSON(b []byte) error { + var v string + if err := json.Unmarshal(b, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_HttpProbeScheme { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_HttpProbeScheme, v) + } + *j = HttpProbeScheme(v) + return nil } -type ServicePortProtocol string +const HttpProbeSchemeHTTP HttpProbeScheme = "HTTP" +const HttpProbeSchemeHTTPS HttpProbeScheme = "HTTPS" -const ServicePortProtocolTCP ServicePortProtocol = "TCP" -const ServicePortProtocolUDP ServicePortProtocol = "UDP" +type Ready string // UnmarshalJSON implements json.Unmarshaler. -func (j *ExecProbe) UnmarshalJSON(b []byte) error { +func (j *HttpProbe) UnmarshalJSON(b []byte) error { var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return err } - if v, ok := raw["command"]; !ok || v == nil { - return fmt.Errorf("field command in ExecProbe: required") + if v, ok := raw["path"]; !ok || v == nil { + return fmt.Errorf("field path in HttpProbe: required") } - type Plain ExecProbe + if v, ok := raw["port"]; !ok || v == nil { + return fmt.Errorf("field port in HttpProbe: required") + } + type Plain HttpProbe var plain Plain if err := json.Unmarshal(b, &plain); err != nil { return err } - *j = ExecProbe(plain) + if plain.Host != nil && len(*plain.Host) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "host", 1) + } + *j = HttpProbe(plain) return nil } -var enumValues_ContainerBeforeReady = []interface{}{ - "started", - "healthy", - "complete", -} - // UnmarshalJSON implements json.Unmarshaler. -func (j *Container) UnmarshalJSON(b []byte) error { +func (j *HttpProbeHttpHeadersElem) UnmarshalJSON(b []byte) error { var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return err } - if v, ok := raw["image"]; !ok || v == nil { - return fmt.Errorf("field image in Container: required") + if v, ok := raw["name"]; !ok || v == nil { + return fmt.Errorf("field name in HttpProbeHttpHeadersElem: required") } - type Plain Container + if v, ok := raw["value"]; !ok || v == nil { + return fmt.Errorf("field value in HttpProbeHttpHeadersElem: required") + } + type Plain HttpProbeHttpHeadersElem var plain Plain if err := json.Unmarshal(b, &plain); err != nil { return err } - if len(plain.Image) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "image", 1) + if len(plain.Value) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "value", 1) } - *j = Container(plain) + *j = HttpProbeHttpHeadersElem(plain) return nil } +// The compute and memory resource limits. +type ResourcesLimits struct { + // The CPU limit as whole or fractional CPUs. 'm' indicates milli-CPUs. For + // example 2 or 125m. + Cpu *string `json:"cpu,omitempty" yaml:"cpu,omitempty" mapstructure:"cpu,omitempty"` + + // The memory limit in bytes with optional unit specifier. For example 125M or + // 1Gi. + Memory *string `json:"memory,omitempty" yaml:"memory,omitempty" mapstructure:"memory,omitempty"` +} + // UnmarshalJSON implements json.Unmarshaler. -func (j *ContainerVolume) UnmarshalJSON(b []byte) error { +func (j *ExecProbe) UnmarshalJSON(b []byte) error { var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return err } - if v, ok := raw["source"]; !ok || v == nil { - return fmt.Errorf("field source in ContainerVolume: required") + if v, ok := raw["command"]; !ok || v == nil { + return fmt.Errorf("field command in ExecProbe: required") } - type Plain ContainerVolume + type Plain ExecProbe var plain Plain if err := json.Unmarshal(b, &plain); err != nil { return err } - *j = ContainerVolume(plain) + *j = ExecProbe(plain) return nil } +const ReadyComplete Ready = "complete" +const ReadyHealthy Ready = "healthy" +const ReadyStarted Ready = "started" + // UnmarshalJSON implements json.Unmarshaler. -func (j *ContainerFile) UnmarshalJSON(b []byte) error { +func (j *Container) UnmarshalJSON(b []byte) error { var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return err } - type Plain ContainerFile + if v, ok := raw["image"]; !ok || v == nil { + return fmt.Errorf("field image in Container: required") + } + type Plain Container var plain Plain if err := json.Unmarshal(b, &plain); err != nil { return err } - if plain.Source != nil && len(*plain.Source) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "source", 1) + if len(plain.Image) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "image", 1) } - *j = ContainerFile(plain) + *j = Container(plain) return nil } // UnmarshalJSON implements json.Unmarshaler. -func (j *HttpProbeScheme) UnmarshalJSON(b []byte) error { +func (j *Ready) UnmarshalJSON(b []byte) error { var v string if err := json.Unmarshal(b, &v); err != nil { return err } var ok bool - for _, expected := range enumValues_HttpProbeScheme { + for _, expected := range enumValues_Ready { if reflect.DeepEqual(v, expected) { ok = true break } } if !ok { - return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_HttpProbeScheme, v) + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_Ready, v) } - *j = HttpProbeScheme(v) + *j = Ready(v) return nil } // UnmarshalJSON implements json.Unmarshaler. -func (j *HttpProbe) UnmarshalJSON(b []byte) error { +func (j *ContainerFile) UnmarshalJSON(b []byte) error { var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return err } - if v, ok := raw["path"]; !ok || v == nil { - return fmt.Errorf("field path in HttpProbe: required") - } - if v, ok := raw["port"]; !ok || v == nil { - return fmt.Errorf("field port in HttpProbe: required") - } - type Plain HttpProbe + type Plain ContainerFile var plain Plain if err := json.Unmarshal(b, &plain); err != nil { return err } - if plain.Host != nil && len(*plain.Host) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "host", 1) + if plain.Source != nil && len(*plain.Source) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "source", 1) } - *j = HttpProbe(plain) + *j = ContainerFile(plain) return nil } -var enumValues_ServicePortProtocol = []interface{}{ - "TCP", - "UDP", +var enumValues_Ready = []interface{}{ + "started", + "healthy", + "complete", +} +var enumValues_HttpProbeScheme = []interface{}{ + "HTTP", + "HTTPS", +} + +// The metadata for the Resource. +type ResourceMetadata map[string]interface{} + +// Optional parameters used to provision the Resource in the environment. +type ResourceParams map[string]interface{} + +// The set of Resources associated with this Workload. +type Resource struct { + // An optional specialisation of the Resource type. + Class *string `json:"class,omitempty" yaml:"class,omitempty" mapstructure:"class,omitempty"` + + // An optional Resource identifier. The id may be up to 63 characters, including + // one or more labels of a-z, 0-9, '-' not starting or ending with '-' separated + // by '.'. When two resources share the same type, class, and id, they are + // considered the same resource when used across related Workloads. + Id *string `json:"id,omitempty" yaml:"id,omitempty" mapstructure:"id,omitempty"` + + // The metadata for the Resource. + Metadata ResourceMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty" mapstructure:"metadata,omitempty"` + + // Optional parameters used to provision the Resource in the environment. + Params ResourceParams `json:"params,omitempty" yaml:"params,omitempty" mapstructure:"params,omitempty"` + + // The Resource type. This should be a type supported by the Score implementations + // being used. + Type string `json:"type" yaml:"type" mapstructure:"type"` } // UnmarshalJSON implements json.Unmarshaler. @@ -376,6 +395,13 @@ func (j *Resource) UnmarshalJSON(b []byte) error { return nil } +type ServicePortProtocol string + +var enumValues_ServicePortProtocol = []interface{}{ + "TCP", + "UDP", +} + // UnmarshalJSON implements json.Unmarshaler. func (j *ServicePortProtocol) UnmarshalJSON(b []byte) error { var v string @@ -396,53 +422,19 @@ func (j *ServicePortProtocol) UnmarshalJSON(b []byte) error { return nil } -// UnmarshalJSON implements json.Unmarshaler. -func (j *ContainerBeforeReady) UnmarshalJSON(b []byte) error { - var v string - if err := json.Unmarshal(b, &v); err != nil { - return err - } - var ok bool - for _, expected := range enumValues_ContainerBeforeReady { - if reflect.DeepEqual(v, expected) { - ok = true - break - } - } - if !ok { - return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_ContainerBeforeReady, v) - } - *j = ContainerBeforeReady(v) - return nil -} +const ServicePortProtocolTCP ServicePortProtocol = "TCP" +const ServicePortProtocolUDP ServicePortProtocol = "UDP" -// UnmarshalJSON implements json.Unmarshaler. -func (j *HttpProbeHttpHeadersElem) UnmarshalJSON(b []byte) error { - var raw map[string]interface{} - if err := json.Unmarshal(b, &raw); err != nil { - return err - } - if v, ok := raw["name"]; !ok || v == nil { - return fmt.Errorf("field name in HttpProbeHttpHeadersElem: required") - } - if v, ok := raw["value"]; !ok || v == nil { - return fmt.Errorf("field value in HttpProbeHttpHeadersElem: required") - } - type Plain HttpProbeHttpHeadersElem - var plain Plain - if err := json.Unmarshal(b, &plain); err != nil { - return err - } - if len(plain.Value) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "value", 1) - } - *j = HttpProbeHttpHeadersElem(plain) - return nil -} +// The network port description. +type ServicePort struct { + // The public service port. + Port int `json:"port" yaml:"port" mapstructure:"port"` -var enumValues_HttpProbeScheme = []interface{}{ - "HTTP", - "HTTPS", + // The transport level protocol. Defaults to TCP. + Protocol *ServicePortProtocol `json:"protocol,omitempty" yaml:"protocol,omitempty" mapstructure:"protocol,omitempty"` + + // The internal service port. This will default to 'port' if not provided. + TargetPort *int `json:"targetPort,omitempty" yaml:"targetPort,omitempty" mapstructure:"targetPort,omitempty"` } // UnmarshalJSON implements json.Unmarshaler. From 59ed853fb917d59d847fc2a7132ee5fd5e0e8e21 Mon Sep 17 00:00:00 2001 From: lekaf974 Date: Thu, 28 May 2026 21:44:03 -0400 Subject: [PATCH 3/5] feat: enhance shorthand support for files and volumes in YAML schema Signed-off-by: lekaf974 --- schema/files/samples/score-full.yaml | 30 ++++++++++++++-------------- schema/validate_test.go | 2 ++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/schema/files/samples/score-full.yaml b/schema/files/samples/score-full.yaml index 5e70b1a..88e6516 100644 --- a/schema/files/samples/score-full.yaml +++ b/schema/files/samples/score-full.yaml @@ -27,33 +27,33 @@ containers: variables: SOME_VAR: some content here files: - /my/file: + - target: /my/file mode: "0600" source: file.txt - /my/other/file: + - target: /my/other/file content: | some multiline content - /my/other/binaryfile: + - target: /my/other/binaryfile binaryContent: ADBgwpA= - /my/other/file-short: "some content here" + - /my/other/file-short: some content here volumes: - /mnt/something: - source: volume-name + - source: volume-name + target: /mnt/something path: /sub/path readOnly: false - /mnt/something-else: - source: volume-two - /mnt/something-short: "volume-short" + - source: volume-two + target: /mnt/something-else + - /mnt/other: volume-three livenessProbe: httpGet: port: 8080 path: /livez exec: command: - - /bin/curl - - -f - - "http://localhost:8080/livez" + - /bin/curl + - -f + - "http://localhost:8080/livez" readinessProbe: httpGet: host: 127.0.0.1 @@ -61,8 +61,8 @@ containers: scheme: HTTP path: /readyz httpHeaders: - - name: SOME_HEADER - value: some-value-here + - name: SOME_HEADER + value: some-value-here container-two2: image: . resources: @@ -81,4 +81,4 @@ resources: type: Resource-Two resource-three: type: Type-Three - id: shared-type-three + id: shared-type-three \ No newline at end of file diff --git a/schema/validate_test.go b/schema/validate_test.go index 49c1d34..82e10d6 100644 --- a/schema/validate_test.go +++ b/schema/validate_test.go @@ -132,11 +132,13 @@ containers: - target: /etc/hello-world/config.yaml mode: "666" content: "${resources.env.APP_CONFIG}" + - /etc/hello-world/config-short.yaml: "${resources.env.APP_CONFIG}" volumes: - source: ${resources.data} path: sub/path target: /mnt/data readOnly: true + - /volumes/data/short: ${resources.data} resources: limits: memory: "128Mi" From 42b6be1563b0cce612a16552033c49c642e47950 Mon Sep 17 00:00:00 2001 From: lekaf974 Date: Thu, 28 May 2026 23:07:08 -0400 Subject: [PATCH 4/5] feat: implement normalization for container files and volumes with shorthand support Signed-off-by: lekaf974 --- loader/normalize.go | 115 +++++++++++++++--- loader/validate.go | 98 ++++++++++++++- loader/validate_test.go | 29 ++--- .../score-deprecated-files-and-volumes.yaml | 16 +++ 4 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 schema/files/samples/score-deprecated-files-and-volumes.yaml diff --git a/loader/normalize.go b/loader/normalize.go index 493db03..b596029 100644 --- a/loader/normalize.go +++ b/loader/normalize.go @@ -19,6 +19,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "unicode/utf8" "github.com/score-spec/score-go/types" @@ -29,24 +30,110 @@ import ( func Normalize(w *types.Workload, baseDir string) error { for name, c := range w.Containers { for target, f := range c.Files { - if f.Source != nil { - raw, err := readFile(baseDir, *f.Source) - if err != nil { - return fmt.Errorf("embedding file '%s' for container '%s': %w", *f.Source, name, err) - } - f.Source = nil - if utf8.Valid(raw) { - content := string(raw) - f.Content = &content - } else { - content := base64.StdEncoding.EncodeToString(raw) - f.BinaryContent = &content - } - c.Files[target] = f + updated, changed, err := normalizeContainerFile(f, baseDir) + if err != nil { + return fmt.Errorf("embedding file for target '%s' in container '%s': %w", target, name, err) } + if changed { + c.Files[target] = updated + } + } + } + + return nil +} + +func normalizeContainerFile(file any, baseDir string) (any, bool, error) { + switch f := file.(type) { + case string: + raw, err := readFile(baseDir, f) + if err != nil { + return nil, false, fmt.Errorf("'%s': %w", f, err) + } + if utf8.Valid(raw) { + return map[string]any{"content": string(raw)}, true, nil + } + return map[string]any{"binaryContent": base64.StdEncoding.EncodeToString(raw)}, true, nil + case map[string]any: + source, ok := f["source"].(string) + if !ok || source == "" { + return file, false, nil + } + raw, err := readFile(baseDir, source) + if err != nil { + return nil, false, fmt.Errorf("'%s': %w", source, err) + } + delete(f, "source") + if utf8.Valid(raw) { + f["content"] = string(raw) + } else { + f["binaryContent"] = base64.StdEncoding.EncodeToString(raw) + } + return f, true, nil + } + + v := reflect.ValueOf(file) + if !v.IsValid() { + return file, false, nil + } + + if v.Kind() == reflect.Ptr { + if v.IsNil() || v.Elem().Kind() != reflect.Struct { + return file, false, nil + } + copy := reflect.New(v.Elem().Type()) + copy.Elem().Set(v.Elem()) + changed, err := normalizeContainerFileValue(copy.Elem(), baseDir) + if err != nil || !changed { + return file, changed, err } + return copy.Interface(), true, nil } + if v.Kind() != reflect.Struct { + return file, false, nil + } + + copy := reflect.New(v.Type()).Elem() + copy.Set(v) + changed, err := normalizeContainerFileValue(copy, baseDir) + if err != nil || !changed { + return file, changed, err + } + return copy.Interface(), true, nil +} + +func normalizeContainerFileValue(v reflect.Value, baseDir string) (bool, error) { + sourceField := v.FieldByName("Source") + if !sourceField.IsValid() || sourceField.Kind() != reflect.Ptr || sourceField.IsNil() { + return false, nil + } + + sourceValue := sourceField.Elem() + if sourceValue.Kind() != reflect.String { + return false, nil + } + + source := sourceValue.String() + raw, err := readFile(baseDir, source) + if err != nil { + return false, fmt.Errorf("'%s': %w", source, err) + } + + sourceField.Set(reflect.Zero(sourceField.Type())) + if utf8.Valid(raw) { + return true, setStringPtrField(v.FieldByName("Content"), string(raw)) + } + return true, setStringPtrField(v.FieldByName("BinaryContent"), base64.StdEncoding.EncodeToString(raw)) +} + +func setStringPtrField(field reflect.Value, value string) error { + if !field.IsValid() || field.Kind() != reflect.Ptr || field.Type().Elem().Kind() != reflect.String { + return nil + } + v := reflect.New(field.Type().Elem()) + v.Elem().SetString(value) + field.Set(v) return nil } diff --git a/loader/validate.go b/loader/validate.go index 5abdb8c..b852e1d 100644 --- a/loader/validate.go +++ b/loader/validate.go @@ -16,6 +16,7 @@ package loader import ( "fmt" + "reflect" "regexp" "strings" @@ -71,8 +72,59 @@ func listAllPlaceholders(workload *types.Workload) []string { placeholderSet := map[string]struct{}{} for _, container := range workload.Containers { for _, file := range container.Files { - if (file.NoExpand == nil || !*file.NoExpand) && file.Content != nil { - for _, placeholder := range allPlaceholdersInString(*file.Content) { + noExpand := false + var content *string + + switch f := file.(type) { + case string: + c := f + content = &c + case map[string]any: + if v, ok := f["noExpand"].(bool); ok { + noExpand = v + } + if v, ok := f["content"].(string); ok { + c := v + content = &c + } + default: + rv := reflect.ValueOf(file) + if rv.IsValid() { + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + break + } + rv = rv.Elem() + } + if rv.Kind() == reflect.Struct { + if nf := rv.FieldByName("NoExpand"); nf.IsValid() { + switch nf.Kind() { + case reflect.Bool: + noExpand = nf.Bool() + case reflect.Ptr: + if !nf.IsNil() && nf.Elem().Kind() == reflect.Bool { + noExpand = nf.Elem().Bool() + } + } + } + if cf := rv.FieldByName("Content"); cf.IsValid() { + switch cf.Kind() { + case reflect.String: + c := cf.String() + content = &c + case reflect.Ptr: + if !cf.IsNil() && cf.Elem().Kind() == reflect.String { + c := cf.Elem().String() + content = &c + } + } + } + } + } + } + + if !noExpand && content != nil { + for _, placeholder := range allPlaceholdersInString(*content) { placeholderSet[placeholder] = struct{}{} } } @@ -83,8 +135,46 @@ func listAllPlaceholders(workload *types.Workload) []string { } } for _, volume := range container.Volumes { - for _, placeholder := range allPlaceholdersInString(volume.Source) { - placeholderSet[placeholder] = struct{}{} + var source *string + + switch v := volume.(type) { + case string: + s := v + source = &s + case map[string]any: + if s, ok := v["source"].(string); ok { + source = &s + } + default: + rv := reflect.ValueOf(volume) + if rv.IsValid() { + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + break + } + rv = rv.Elem() + } + if rv.Kind() == reflect.Struct { + if sf := rv.FieldByName("Source"); sf.IsValid() { + switch sf.Kind() { + case reflect.String: + s := sf.String() + source = &s + case reflect.Ptr: + if !sf.IsNil() && sf.Elem().Kind() == reflect.String { + s := sf.Elem().String() + source = &s + } + } + } + } + } + } + + if source != nil { + for _, placeholder := range allPlaceholdersInString(*source) { + placeholderSet[placeholder] = struct{}{} + } } } } diff --git a/loader/validate_test.go b/loader/validate_test.go index 1753c04..60f77e9 100644 --- a/loader/validate_test.go +++ b/loader/validate_test.go @@ -100,22 +100,23 @@ func TestValidatePlaceholders(t *testing.T) { { name: "valid", files: types.ContainerFiles{ - "/usr/local/one": { + "/usr/local/one": types.ContainerFile{ Content: stringRef("Placeholder ${resources.res-one.value}"), }, - "/usr/local/two": { + "/usr/local/two": types.ContainerFile{ Content: stringRef("Placeholder ${resources.res-two.value} ${resources.res-two.other}"), }, - "/usr/local/three": { + "/usr/local/three": types.ContainerFile{ Content: stringRef("No placeholders"), }, - "/usr/local/four": { + "/usr/local/four": types.ContainerFile{ Content: stringRef("Escaped $${placeholder}"), }, - "/usr/local/five": { + "/usr/local/five": types.ContainerFile{ Content: stringRef("Invalid placeholder with NoExpand: ${this is invalid}"), NoExpand: boolRef(true), }, + "/usr/local/six": stringRef("Placeholder ${resources.res-one.value}"), }, variables: types.ContainerVariables{ "VAR_ONE": "Placeholder ${resources.res-one.value}", @@ -124,15 +125,16 @@ func TestValidatePlaceholders(t *testing.T) { "VAR_FOUR": "Escaped $${resources.no-exists.value}", }, volumes: types.ContainerVolumes{ - "/mnt/one": { + "/mnt/one": types.ContainerVolume{ Source: "${resources.res-one}", }, + "/mnt/two": "${resources.res-one}", }, resources: types.WorkloadResources{ - "res-one": { + "res-one": types.Resource{ Type: "type-one", }, - "res-two": { + "res-two": types.Resource{ Type: "type-two", Params: types.ResourceParams{ "var": "${resources.res-one.value}", @@ -179,10 +181,10 @@ func TestValidatePlaceholders(t *testing.T) { { name: "multiple errors", files: types.ContainerFiles{ - "/usr/local/one": { + "/usr/local/one": types.ContainerFile{ Content: stringRef("Placeholder ${resources.res-one.value}"), }, - "/usr/local/two": { + "/usr/local/two": types.ContainerFile{ Content: stringRef("Placeholder ${resources.no-exist.value}"), }, }, @@ -190,15 +192,15 @@ func TestValidatePlaceholders(t *testing.T) { "VAR_ONE": "Placeholder ${invalid!}", }, volumes: types.ContainerVolumes{ - "/mnt/one": { + "/mnt/one": types.ContainerVolume{ Source: "${resources.another-no-exist}", }, }, resources: types.WorkloadResources{ - "res-one": { + "res-one": types.Resource{ Type: "type-one", }, - "res-two": { + "res-two": types.Resource{ Type: "type-two", Params: types.ResourceParams{ "var": "${resources.yet-another-no-exist.value}", @@ -338,4 +340,3 @@ func TestValidateContainerBefore(t *testing.T) { }) } } - diff --git a/schema/files/samples/score-deprecated-files-and-volumes.yaml b/schema/files/samples/score-deprecated-files-and-volumes.yaml new file mode 100644 index 0000000..36ba266 --- /dev/null +++ b/schema/files/samples/score-deprecated-files-and-volumes.yaml @@ -0,0 +1,16 @@ +# The following workload describes a container which has a file and volume mounted. The file mount is using the deprecated format of an array of files. +# This was deprecated since files do not infer order and the "target" path can be considered both required and unique. Simalarly, the volume mount is also +# using a deprecated array format. +# ========================================================================================================================================== +apiVersion: score.dev/v1b1 +metadata: + name: "deprecated-files-example" +containers: + main: + image: "example-image:latest" + files: + - target: /mnt/some-path + content: "my content here" + volumes: + - target: /mnt/vol + source: some-volume From 07cd25cb97ba6a2839e0730559adbffc23104c27 Mon Sep 17 00:00:00 2001 From: lekaf974 Date: Wed, 3 Jun 2026 21:59:53 -0400 Subject: [PATCH 5/5] feat: add shorthand support for container files and volumes in tests and normalization Signed-off-by: lekaf974 --- loader/loader_test.go | 13 ++++++++----- loader/normalize.go | 2 +- loader/normalize_test.go | 20 +++++++++++--------- loader/validate_test.go | 8 +++++--- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/loader/loader_test.go b/loader/loader_test.go index 37c05c0..2eba696 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -91,6 +91,8 @@ containers: noExpand: true /etc/hello-world/binary: binaryContent: aGVsbG8= + /etc/hello-world/short: | + short content volumes: /mnt/data: source: ${resources.data} @@ -165,18 +167,19 @@ resources: Variables: map[string]string{ "FRIEND": "World!", }, - Files: map[string]types.ContainerFile{ - "/etc/hello-world/config.yaml": { + Files: types.ContainerFiles{ + "/etc/hello-world/config.yaml": types.ContainerFile{ Mode: stringRef("666"), Content: stringRef("---\n${resources.env.APP_CONFIG}\n"), NoExpand: boolRef(true), }, - "/etc/hello-world/binary": { + "/etc/hello-world/binary": types.ContainerFile{ BinaryContent: stringRef("aGVsbG8="), }, + "/etc/hello-world/short": stringRef("short content\n"), }, - Volumes: map[string]types.ContainerVolume{ - "/mnt/data": { + Volumes: types.ContainerVolumes{ + "/mnt/data": types.ContainerVolume{ Source: "${resources.data}", Path: stringRef("sub/path"), ReadOnly: boolRef(true), diff --git a/loader/normalize.go b/loader/normalize.go index b596029..d765622 100644 --- a/loader/normalize.go +++ b/loader/normalize.go @@ -32,7 +32,7 @@ func Normalize(w *types.Workload, baseDir string) error { for target, f := range c.Files { updated, changed, err := normalizeContainerFile(f, baseDir) if err != nil { - return fmt.Errorf("embedding file for target '%s' in container '%s': %w", target, name, err) + return fmt.Errorf("embedding file '%s' for container '%s': %w", target, name, err) } if changed { c.Files[target] = updated diff --git a/loader/normalize_test.go b/loader/normalize_test.go index aa812c4..88a8248 100644 --- a/loader/normalize_test.go +++ b/loader/normalize_test.go @@ -41,15 +41,16 @@ func TestNormalize(t *testing.T) { }, Containers: types.WorkloadContainers{ "hello": types.Container{ - Files: map[string]types.ContainerFile{ - "/etc/hello-world/config.yaml": { + Files: types.ContainerFiles{ + "/etc/hello-world/config.yaml": types.ContainerFile{ Source: stringRef("./test_file.txt"), Mode: stringRef("666"), NoExpand: boolRef(true), }, - "/etc/hello-world/binary": { + "/etc/hello-world/binary": types.ContainerFile{ Source: stringRef("./test_binary_file"), }, + "/etc/hello-world/short.txt": stringRef("short content"), }, }, }, @@ -61,15 +62,16 @@ func TestNormalize(t *testing.T) { }, Containers: types.WorkloadContainers{ "hello": types.Container{ - Files: map[string]types.ContainerFile{ - "/etc/hello-world/config.yaml": { + Files: types.ContainerFiles{ + "/etc/hello-world/config.yaml": types.ContainerFile{ Mode: stringRef("666"), Content: stringRef("Hello World\n"), NoExpand: boolRef(true), }, - "/etc/hello-world/binary": { + "/etc/hello-world/binary": types.ContainerFile{ BinaryContent: stringRef("XVLOjEyq5FKgHDGMAYMdp+crq4I="), }, + "/etc/hello-world/short.txt": stringRef("short content"), }, }, }, @@ -84,8 +86,8 @@ func TestNormalize(t *testing.T) { }, Containers: types.WorkloadContainers{ "hello": types.Container{ - Files: map[string]types.ContainerFile{ - "/etc/hello-world/config.yaml": { + Files: types.ContainerFiles{ + "/etc/hello-world/config.yaml": types.ContainerFile{ Source: stringRef("./not_existing.txt"), Mode: stringRef("666"), NoExpand: boolRef(true), @@ -94,7 +96,7 @@ func TestNormalize(t *testing.T) { }, }, }, - Error: errors.New("embedding file './not_existing.txt' for container 'hello': open fixtures/not_existing.txt: no such file or directory"), + Error: errors.New("embedding file '/etc/hello-world/config.yaml' for container 'hello': './not_existing.txt': open fixtures/not_existing.txt: no such file or directory"), }, } diff --git a/loader/validate_test.go b/loader/validate_test.go index 60f77e9..3d022bd 100644 --- a/loader/validate_test.go +++ b/loader/validate_test.go @@ -234,7 +234,9 @@ func TestValidatePlaceholders(t *testing.T) { func before(containers ...string) types.ContainerBefore { b := types.ContainerBefore{} for _, c := range containers { - b[c] = types.ContainerBeforeEntry{Ready: types.ContainerBeforeReadyStarted} + v := b[c] + v.Ready = types.ReadyStarted + b[c] = v } return b } @@ -314,8 +316,8 @@ func TestValidateContainerBefore(t *testing.T) { name: "unknown and cycle are both reported", containers: types.WorkloadContainers{ "a": {Image: "img", Before: types.ContainerBefore{ - "ghost": {Ready: types.ContainerBeforeReadyStarted}, - "b": {Ready: types.ContainerBeforeReadyStarted}, + "ghost": {Ready: types.ReadyStarted}, + "b": {Ready: types.ReadyStarted}, }}, "b": {Image: "img", Before: before("a")}, },