obsuild2: support additional layers in oci-archive

The org.osbuild.oci-archive stage now supports an arbitrary number of
layers on top of the Base layer.  The keys for these layers follow the
pattern "layer.N" (N = 1, 2, 3, ...).

We use a custom marshaller and unmarshaller for the
OCIArchiveStageInputs to handle this.  The unmarshaller also validates
the layer keys to match the pattern in the schema.
This commit is contained in:
Achilleas Koutsou 2021-03-02 21:25:37 +01:00 committed by Tom Gundersen
parent dc5e46139a
commit f107241ee2
2 changed files with 193 additions and 0 deletions

View file

@ -1,5 +1,13 @@
package osbuild2
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"sort"
)
type OCIArchiveStageOptions struct {
// The CPU architecture of the image
Architecture string `json:"architecture"`
@ -25,7 +33,10 @@ type OCIArchiveConfig struct {
func (OCIArchiveStageOptions) isStageOptions() {}
type OCIArchiveStageInputs struct {
// Base layer for the container
Base *OCIArchiveStageInput `json:"base"`
// Additional layers in ascending order
Layers []OCIArchiveStageInput `json:",omitempty"`
}
func (OCIArchiveStageInputs) isStageInputs() {}
@ -49,3 +60,80 @@ func NewOCIArchiveStage(options *OCIArchiveStageOptions, inputs *OCIArchiveStage
Inputs: inputs,
}
}
// Custom marshaller for OCIArchiveStageInputs, needed to generate keys of the
// form "layer.N", (where N = 1, 2, ...) for the Layers property
func (inputs *OCIArchiveStageInputs) MarshalJSON() ([]byte, error) {
if inputs == nil {
return json.Marshal(inputs)
}
layers := inputs.Layers
inputsMap := make(map[string]OCIArchiveStageInput, len(layers)+1)
if inputs.Base != nil {
inputsMap["base"] = *inputs.Base
}
for idx, input := range layers {
key := fmt.Sprintf("layer.%d", idx+1)
inputsMap[key] = input
}
return json.Marshal(inputsMap)
}
// Get the sorted keys that match the pattern "layer.N" (for N > 0)
func layerKeys(layers map[string]OCIArchiveStageInput) ([]string, error) {
keys := make([]string, 0, len(layers))
for key := range layers {
re := regexp.MustCompile(`layer\.[1-9]\d*`)
if key == "base" {
continue
}
if !re.MatchString(key) {
return nil, fmt.Errorf("invalid key: %q", key)
}
keys = append(keys, key)
}
sort.Strings(keys)
return keys, nil
}
// Custom unmarshaller for OCIArchiveStageInputs, needed to handle keys of the
// form "layer.N", (where N = 1, 2, ...) for the Layers property
func (inputs *OCIArchiveStageInputs) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
if inputs == nil {
inputs = new(OCIArchiveStageInputs)
}
inputsMap := make(map[string]OCIArchiveStageInput)
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&inputsMap); err != nil {
return err
}
// "base" layer is required
base, ok := inputsMap["base"]
if !ok {
return fmt.Errorf("missing required key \"base\"")
}
inputs.Base = &base
keys, err := layerKeys(inputsMap)
if err != nil {
return err
}
inputs.Layers = make([]OCIArchiveStageInput, len(inputsMap)-1)
for idx, key := range keys {
inputs.Layers[idx] = inputsMap[key]
}
return nil
}

View file

@ -0,0 +1,105 @@
package osbuild2
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestOCIArchiveStage(t *testing.T) {
expectedStage := &Stage{
Type: "org.osbuild.oci-archive",
Options: &OCIArchiveStageOptions{},
Inputs: &OCIArchiveStageInputs{},
}
actualStage := NewOCIArchiveStage(&OCIArchiveStageOptions{}, &OCIArchiveStageInputs{})
assert.Equal(t, expectedStage, actualStage)
}
func TestOCIArchiveInputs(t *testing.T) {
exp := `{
"base": {
"type": "org.osbuild.oci-archive",
"origin":"org.osbuild.pipeline",
"references": ["name:container-tree"]
},
"layer.1": {
"type": "org.osbuild.tree",
"origin": "org.osbuild.pipeline",
"references": ["name:container-ostree"]
},
"layer.2": {
"type": "org.osbuild.tree",
"origin": "org.osbuild.pipeline",
"references": ["name:container-ostree2"]
}
}`
inputs := new(OCIArchiveStageInputs)
base := &OCIArchiveStageInput{
References: []string{
"name:container-tree",
},
}
base.Type = "org.osbuild.oci-archive"
base.Origin = "org.osbuild.pipeline"
layer1 := OCIArchiveStageInput{
References: []string{
"name:container-ostree",
},
}
layer1.Type = "org.osbuild.tree"
layer1.Origin = "org.osbuild.pipeline"
layer2 := OCIArchiveStageInput{
References: []string{
"name:container-ostree2",
},
}
layer2.Type = "org.osbuild.tree"
layer2.Origin = "org.osbuild.pipeline"
inputs.Base = base
inputs.Layers = []OCIArchiveStageInput{layer1, layer2}
data, err := json.Marshal(inputs)
assert.NoError(t, err)
assert.JSONEq(t, exp, string(data))
inputsRead := new(OCIArchiveStageInputs)
err = json.Unmarshal([]byte(exp), inputsRead)
assert.NoError(t, err)
assert.Equal(t, inputs, inputsRead)
}
func TestOCIArchiveInputsErrors(t *testing.T) {
noBase := `{
"layer.10": {
"type": "org.osbuild.tree",
"origin": "org.osbuild.pipeline",
"references": ["name:container-ostree"]
},
"layer.2": {
"type": "org.osbuild.tree",
"origin": "org.osbuild.pipeline",
"references": ["name:container-ostree2"]
}
}`
inputsRead := new(OCIArchiveStageInputs)
assert.Error(t, json.Unmarshal([]byte(noBase), inputsRead))
invalidKey := `{
"base": {
"type": "org.osbuild.oci-archive",
"origin":"org.osbuild.pipeline",
"references": ["name:container-tree"]
},
"not-a-layer": {
"type": "org.osbuild.tree",
"origin": "org.osbuild.pipeline",
"references": ["name:container-ostree2"]
}
}`
assert.Error(t, json.Unmarshal([]byte(invalidKey), inputsRead))
}