blueprint: add representation of Directories and Files customization

Extend the Blueprint customizations with the representation for custom
Directories and Files specified by the user.

Implement custom Unmarshalers for TOML and JSON. These ensure that all
user-provided values are validated before use and also handle the fact
that user and group ownership for directories and files can be
specifies as a string or as an integer.

Implement helper functions for converting the Blueprint-specific types
for these customizations to their internal representation from `fsnode`
package.

Signed-off-by: Tomáš Hozza <thozza@redhat.com>
This commit is contained in:
Tomáš Hozza 2023-01-31 21:53:24 +01:00 committed by Sanne Raymaekers
parent 26e6983320
commit c1991b3d51
3 changed files with 1098 additions and 0 deletions

View file

@ -21,6 +21,8 @@ type Customizations struct {
FDO *FDOCustomization `json:"fdo,omitempty" toml:"fdo,omitempty"`
OpenSCAP *OpenSCAPCustomization `json:"openscap,omitempty" toml:"openscap,omitempty"`
Ignition *IgnitionCustomization `json:"ignition,omitempty" toml:"ignition,omitempty"`
Directories []DirectoryCustomization `json:"directories,omitempty" toml:"directories,omitempty"`
Files []FileCustomization `json:"files,omitempty" toml:"files,omitempty"`
}
type IgnitionCustomization struct {
@ -341,3 +343,17 @@ func (c *Customizations) GetIgnition() *IgnitionCustomization {
}
return c.Ignition
}
func (c *Customizations) GetDirectories() []DirectoryCustomization {
if c == nil {
return nil
}
return c.Directories
}
func (c *Customizations) GetFiles() []FileCustomization {
if c == nil {
return nil
}
return c.Files
}

View file

@ -0,0 +1,285 @@
package blueprint
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strconv"
"github.com/osbuild/osbuild-composer/internal/common"
"github.com/osbuild/osbuild-composer/internal/fsnode"
)
// validateModeString checks that the given string is a valid mode octal number
func validateModeString(mode string) error {
// Check that the mode string matches the octal format regular expression.
// The leading is optional.
if regexp.MustCompile(`^[0]{0,1}[0-7]{3}$`).MatchString(mode) {
return nil
}
return fmt.Errorf("invalid mode %s: must be an octal number", mode)
}
// DirectoryCustomization represents a directory to be created in the image
type DirectoryCustomization struct {
// Absolute path to the directory
Path string `json:"path" toml:"path"`
// Owner of the directory specified as a string (user name), int64 (UID) or nil
User interface{} `json:"user,omitempty" toml:"user,omitempty"`
// Owner of the directory specified as a string (group name), int64 (UID) or nil
Group interface{} `json:"group,omitempty" toml:"group,omitempty"`
// Permissions of the directory specified as an octal number
Mode string `json:"mode,omitempty" toml:"mode,omitempty"`
// EnsureParents ensures that all parent directories of the directory exist
EnsureParents bool `json:"ensure_parents,omitempty" toml:"ensure_parents,omitempty"`
}
// Custom TOML unmarshalling for DirectoryCustomization with validation
func (d *DirectoryCustomization) UnmarshalTOML(data interface{}) error {
var dir DirectoryCustomization
dataMap, _ := data.(map[string]interface{})
switch path := dataMap["path"].(type) {
case string:
dir.Path = path
default:
return fmt.Errorf("UnmarshalTOML: path must be a string")
}
switch user := dataMap["user"].(type) {
case string:
dir.User = user
case int64:
dir.User = user
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: user must be a string or an integer, got %T", user)
}
switch group := dataMap["group"].(type) {
case string:
dir.Group = group
case int64:
dir.Group = group
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: group must be a string or an integer")
}
switch mode := dataMap["mode"].(type) {
case string:
dir.Mode = mode
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: mode must be a string")
}
switch ensureParents := dataMap["ensure_parents"].(type) {
case bool:
dir.EnsureParents = ensureParents
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: ensure_parents must be a bool")
}
// try converting to fsnode.Directory to validate all values
_, err := dir.ToFsNodeDirectory()
if err != nil {
return err
}
*d = dir
return nil
}
// Custom JSON unmarshalling for DirectoryCustomization with validation
func (d *DirectoryCustomization) UnmarshalJSON(data []byte) error {
type directoryCustomization DirectoryCustomization
var dirPrivate directoryCustomization
if err := json.Unmarshal(data, &dirPrivate); err != nil {
return err
}
dir := DirectoryCustomization(dirPrivate)
if uid, ok := dir.User.(float64); ok {
// check if uid can be converted to int64
if uid != float64(int64(uid)) {
return fmt.Errorf("invalid user %f: must be an integer", uid)
}
dir.User = int64(uid)
}
if gid, ok := dir.Group.(float64); ok {
// check if gid can be converted to int64
if gid != float64(int64(gid)) {
return fmt.Errorf("invalid group %f: must be an integer", gid)
}
dir.Group = int64(gid)
}
// try converting to fsnode.Directory to validate all values
_, err := dir.ToFsNodeDirectory()
if err != nil {
return err
}
*d = dir
return nil
}
// ToFsNodeDirectory converts the DirectoryCustomization to an fsnode.Directory
func (d DirectoryCustomization) ToFsNodeDirectory() (*fsnode.Directory, error) {
var mode *os.FileMode
if d.Mode != "" {
err := validateModeString(d.Mode)
if err != nil {
return nil, err
}
modeNum, err := strconv.ParseUint(d.Mode, 8, 32)
if err != nil {
return nil, fmt.Errorf("invalid mode %s: %v", d.Mode, err)
}
mode = common.ToPtr(os.FileMode(modeNum))
}
return fsnode.NewDirectory(d.Path, mode, d.User, d.Group, d.EnsureParents)
}
// FileCustomization represents a file to be created in the image
type FileCustomization struct {
// Absolute path to the file
Path string `json:"path" toml:"path"`
// Owner of the directory specified as a string (user name), int64 (UID) or nil
User interface{} `json:"user,omitempty" toml:"user,omitempty"`
// Owner of the directory specified as a string (group name), int64 (UID) or nil
Group interface{} `json:"group,omitempty" toml:"group,omitempty"`
// Permissions of the file specified as an octal number
Mode string `json:"mode,omitempty" toml:"mode,omitempty"`
// Data is the file content in plain text
Data string `json:"data,omitempty" toml:"data,omitempty"`
}
// Custom TOML unmarshalling for FileCustomization with validation
func (f *FileCustomization) UnmarshalTOML(data interface{}) error {
var file FileCustomization
dataMap, _ := data.(map[string]interface{})
switch path := dataMap["path"].(type) {
case string:
file.Path = path
default:
return fmt.Errorf("UnmarshalTOML: path must be a string")
}
switch user := dataMap["user"].(type) {
case string:
file.User = user
case int64:
file.User = user
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: user must be a string or an integer")
}
switch group := dataMap["group"].(type) {
case string:
file.Group = group
case int64:
file.Group = group
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: group must be a string or an integer")
}
switch mode := dataMap["mode"].(type) {
case string:
file.Mode = mode
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: mode must be a string")
}
switch data := dataMap["data"].(type) {
case string:
file.Data = data
case nil:
break
default:
return fmt.Errorf("UnmarshalTOML: data must be a string")
}
// try converting to fsnode.File to validate all values
_, err := file.ToFsNodeFile()
if err != nil {
return err
}
*f = file
return nil
}
// Custom JSON unmarshalling for FileCustomization with validation
func (f *FileCustomization) UnmarshalJSON(data []byte) error {
type fileCustomization FileCustomization
var filePrivate fileCustomization
if err := json.Unmarshal(data, &filePrivate); err != nil {
return err
}
file := FileCustomization(filePrivate)
if uid, ok := file.User.(float64); ok {
// check if uid can be converted to int64
if uid != float64(int64(uid)) {
return fmt.Errorf("invalid user %f: must be an integer", uid)
}
file.User = int64(uid)
}
if gid, ok := file.Group.(float64); ok {
// check if gid can be converted to int64
if gid != float64(int64(gid)) {
return fmt.Errorf("invalid group %f: must be an integer", gid)
}
file.Group = int64(gid)
}
// try converting to fsnode.File to validate all values
_, err := file.ToFsNodeFile()
if err != nil {
return err
}
*f = file
return nil
}
// ToFsNodeFile converts the FileCustomization to an fsnode.File
func (f FileCustomization) ToFsNodeFile() (*fsnode.File, error) {
var data []byte
if f.Data != "" {
data = []byte(f.Data)
}
var mode *os.FileMode
if f.Mode != "" {
err := validateModeString(f.Mode)
if err != nil {
return nil, err
}
modeNum, err := strconv.ParseUint(f.Mode, 8, 32)
if err != nil {
return nil, fmt.Errorf("invalid mode %s: %v", f.Mode, err)
}
mode = common.ToPtr(os.FileMode(modeNum))
}
return fsnode.NewFile(f.Path, mode, f.User, f.Group, data)
}

View file

@ -0,0 +1,797 @@
package blueprint
import (
"encoding/json"
"os"
"testing"
"github.com/BurntSushi/toml"
"github.com/osbuild/osbuild-composer/internal/common"
"github.com/osbuild/osbuild-composer/internal/fsnode"
"github.com/stretchr/testify/assert"
)
func TestDirectoryCustomizationToFsNodeDirectory(t *testing.T) {
ensureDirCreation := func(dir *fsnode.Directory, err error) *fsnode.Directory {
t.Helper()
assert.NoError(t, err)
assert.NotNil(t, dir)
return dir
}
testCases := []struct {
Name string
Dir DirectoryCustomization
WantDir *fsnode.Directory
Error bool
}{
{
Name: "empty",
Dir: DirectoryCustomization{},
Error: true,
},
{
Name: "path-only",
Dir: DirectoryCustomization{
Path: "/etc/dir",
},
WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, nil, nil, false)),
},
{
Name: "path-invalid",
Dir: DirectoryCustomization{
Path: "etc/dir",
},
Error: true,
},
{
Name: "path-and-mode",
Dir: DirectoryCustomization{
Path: "/etc/dir",
Mode: "0700",
},
WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", common.ToPtr(os.FileMode(0700)), nil, nil, false)),
},
{
Name: "path-and-mode-no-leading-zero",
Dir: DirectoryCustomization{
Path: "/etc/dir",
Mode: "700",
},
WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", common.ToPtr(os.FileMode(0700)), nil, nil, false)),
},
{
Name: "path-and-mode-invalid",
Dir: DirectoryCustomization{
Path: "/etc/dir",
Mode: "12345",
},
Error: true,
},
{
Name: "path-user-group-string",
Dir: DirectoryCustomization{
Path: "/etc/dir",
User: "root",
Group: "root",
},
WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, "root", "root", false)),
},
{
Name: "path-user-group-int64",
Dir: DirectoryCustomization{
Path: "/etc/dir",
User: int64(0),
Group: int64(0),
},
WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, int64(0), int64(0), false)),
},
{
Name: "path-and-user-invalid-string",
Dir: DirectoryCustomization{
Path: "/etc/dir",
User: "r@@t",
},
Error: true,
},
{
Name: "path-and-user-invalid-int64",
Dir: DirectoryCustomization{
Path: "/etc/dir",
User: -1,
},
Error: true,
},
{
Name: "path-and-group-invalid-string",
Dir: DirectoryCustomization{
Path: "/etc/dir",
Group: "r@@t",
},
Error: true,
},
{
Name: "path-and-group-invalid-int64",
Dir: DirectoryCustomization{
Path: "/etc/dir",
Group: -1,
},
Error: true,
},
{
Name: "path-and-ensure-parent-dirs",
Dir: DirectoryCustomization{
Path: "/etc/dir",
EnsureParents: true,
},
WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, nil, nil, true)),
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
dir, err := tc.Dir.ToFsNodeDirectory()
if tc.Error {
assert.Error(t, err)
assert.Nil(t, dir)
} else {
assert.NoError(t, err)
assert.EqualValues(t, tc.WantDir, dir)
}
})
}
}
func TestDirectoryCustomizationUnmarshalTOML(t *testing.T) {
testCases := []struct {
Name string
TOML string
Want []DirectoryCustomization
Error bool
}{
{
Name: "directory-with-path",
TOML: `
name = "test"
description = "Test"
version = "0.0.0"
[[customizations.directories]]
path = "/etc/dir"
`,
Want: []DirectoryCustomization{
{
Path: "/etc/dir",
},
},
},
{
Name: "multiple-directories",
TOML: `
name = "test"
description = "Test"
version = "0.0.0"
[[customizations.directories]]
path = "/etc/dir1"
mode = "0700"
user = "root"
group = "root"
ensure_parents = true
[[customizations.directories]]
path = "/etc/dir2"
mode = "0755"
user = 0
group = 0
ensure_parents = true
[[customizations.directories]]
path = "/etc/dir3"
`,
Want: []DirectoryCustomization{
{
Path: "/etc/dir1",
Mode: "0700",
User: "root",
Group: "root",
EnsureParents: true,
},
{
Path: "/etc/dir2",
Mode: "0755",
User: int64(0),
Group: int64(0),
EnsureParents: true,
},
{
Path: "/etc/dir3",
},
},
},
{
Name: "invalid-directories",
TOML: `
name = "test"
description = "Test"
version = "0.0.0"
[[customizations.directories]]
path = "/etc/../dir1"
[[customizations.directories]]
path = "/etc/dir2"
mode = "12345"
[[customizations.directories]]
path = "/etc/dir3"
user = "r@@t"
[[customizations.directories]]
path = "/etc/dir4"
group = "r@@t"
[[customizations.directories]]
path = "/etc/dir5"
user = -1
[[customizations.directories]]
path = "/etc/dir6"
group = -1
[[customizations.directories]]
`,
Error: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
var blueprint Blueprint
err := toml.Unmarshal([]byte(tc.TOML), &blueprint)
if tc.Error {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotNil(t, blueprint.Customizations)
assert.Len(t, blueprint.Customizations.Directories, len(tc.Want))
assert.EqualValues(t, tc.Want, blueprint.Customizations.GetDirectories())
}
})
}
}
func TestDirectoryCustomizationUnmarshalJSON(t *testing.T) {
testCases := []struct {
Name string
JSON string
Want []DirectoryCustomization
Error bool
}{
{
Name: "directory-with-path",
JSON: `
{
"name": "test",
"description": "Test",
"version": "0.0.0",
"customizations": {
"directories": [
{
"path": "/etc/dir"
}
]
}
}`,
Want: []DirectoryCustomization{
{
Path: "/etc/dir",
},
},
},
{
Name: "multiple-directories",
JSON: `
{
"name": "test",
"description": "Test",
"version": "0.0.0",
"customizations": {
"directories": [
{
"path": "/etc/dir1",
"mode": "0700",
"user": "root",
"group": "root",
"ensure_parents": true
},
{
"path": "/etc/dir2",
"mode": "0755",
"user": 0,
"group": 0,
"ensure_parents": true
},
{
"path": "/etc/dir3"
}
]
}
}`,
Want: []DirectoryCustomization{
{
Path: "/etc/dir1",
Mode: "0700",
User: "root",
Group: "root",
EnsureParents: true,
},
{
Path: "/etc/dir2",
Mode: "0755",
User: int64(0),
Group: int64(0),
EnsureParents: true,
},
{
Path: "/etc/dir3",
},
},
},
{
Name: "invalid-directories",
JSON: `
{
"name": "test",
"description": "Test",
"version": "0.0.0",
"customizations": {
"directories": [
{
"path": "/etc/../dir1"
},
{
"path": "/etc/dir2",
"mode": "12345"
},
{
"path": "/etc/dir3",
"user": "r@@t"
},
{
"path": "/etc/dir4",
"group": "r@@t"
},
{
"path": "/etc/dir5",
"user": -1
},
{
"path": "/etc/dir6",
"group": -1
}
{}
]
}
}`,
Error: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
var blueprint Blueprint
err := json.Unmarshal([]byte(tc.JSON), &blueprint)
if tc.Error {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotNil(t, blueprint.Customizations)
assert.Len(t, blueprint.Customizations.Directories, len(tc.Want))
assert.EqualValues(t, tc.Want, blueprint.Customizations.GetDirectories())
}
})
}
}
func TestFileCustomizationToFsNodeFile(t *testing.T) {
ensureFileCreation := func(file *fsnode.File, err error) *fsnode.File {
t.Helper()
assert.NoError(t, err)
assert.NotNil(t, file)
return file
}
testCases := []struct {
Name string
File FileCustomization
Want *fsnode.File
Error bool
}{
{
Name: "empty",
File: FileCustomization{},
Error: true,
},
{
Name: "path-only",
File: FileCustomization{
Path: "/etc/file",
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, nil, nil)),
},
{
Name: "path-invalid",
File: FileCustomization{
Path: "../etc/file",
},
Error: true,
},
{
Name: "path-and-mode",
File: FileCustomization{
Path: "/etc/file",
Mode: "0700",
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", common.ToPtr(os.FileMode(0700)), nil, nil, nil)),
},
{
Name: "path-and-mode-no-leading-zero",
File: FileCustomization{
Path: "/etc/file",
Mode: "700",
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", common.ToPtr(os.FileMode(0700)), nil, nil, nil)),
},
{
Name: "path-and-mode-invalid",
File: FileCustomization{
Path: "/etc/file",
Mode: "12345",
},
Error: true,
},
{
Name: "path-user-group-string",
File: FileCustomization{
Path: "/etc/file",
User: "root",
Group: "root",
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, "root", "root", nil)),
},
{
Name: "path-user-group-int64",
File: FileCustomization{
Path: "/etc/file",
User: int64(0),
Group: int64(0),
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, int64(0), int64(0), nil)),
},
{
Name: "path-and-user-invalid-string",
File: FileCustomization{
Path: "/etc/file",
User: "r@@t",
},
Error: true,
},
{
Name: "path-and-user-invalid-int64",
File: FileCustomization{
Path: "/etc/file",
User: int64(-1),
},
Error: true,
},
{
Name: "path-and-group-string",
File: FileCustomization{
Path: "/etc/file",
Group: "root",
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, "root", nil)),
},
{
Name: "path-and-group-int64",
File: FileCustomization{
Path: "/etc/file",
Group: int64(0),
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, int64(0), nil)),
},
{
Name: "path-and-group-invalid-string",
File: FileCustomization{
Path: "/etc/file",
Group: "r@@t",
},
Error: true,
},
{
Name: "path-and-group-invalid-int64",
File: FileCustomization{
Path: "/etc/file",
Group: int64(-1),
},
Error: true,
},
{
Name: "path-and-data",
File: FileCustomization{
Path: "/etc/file",
Data: "hello world",
},
Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, nil, []byte("hello world"))),
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
file, err := tc.File.ToFsNodeFile()
if tc.Error {
assert.Error(t, err)
assert.Nil(t, file)
} else {
assert.NoError(t, err)
assert.EqualValues(t, tc.Want, file)
}
})
}
}
func TestFileCustomizationUnmarshalTOML(t *testing.T) {
testCases := []struct {
Name string
TOML string
Want []FileCustomization
Error bool
}{
{
Name: "file-with-path",
TOML: `
name = "test"
description = "Test"
version = "0.0.0"
[[customizations.files]]
path = "/etc/file"
`,
Want: []FileCustomization{
{
Path: "/etc/file",
},
},
},
{
Name: "multiple-files",
TOML: `
name = "test"
description = "Test"
version = "0.0.0"
[[customizations.files]]
path = "/etc/file1"
mode = "0600"
user = "root"
group = "root"
data = "hello world"
[[customizations.files]]
path = "/etc/file2"
mode = "0644"
data = "hello world 2"
[[customizations.files]]
path = "/etc/file3"
user = 0
group = 0
data = "hello world 3"
`,
Want: []FileCustomization{
{
Path: "/etc/file1",
Mode: "0600",
User: "root",
Group: "root",
Data: "hello world",
},
{
Path: "/etc/file2",
Mode: "0644",
Data: "hello world 2",
},
{
Path: "/etc/file3",
User: int64(0),
Group: int64(0),
Data: "hello world 3",
},
},
},
{
Name: "invalid-files",
TOML: `
name = "test"
description = "Test"
version = "0.0.0"
[[customizations.files]]
path = "/etc/../file1"
[[customizations.files]]
path = "/etc/file2"
mode = "12345"
[[customizations.files]]
path = "/etc/file3"
user = "r@@t"
[[customizations.files]]
path = "/etc/file4"
group = "r@@t"
[[customizations.files]]
path = "/etc/file5"
user = -1
[[customizations.files]]
path = "/etc/file6"
group = -1
`,
Error: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
var blueprint Blueprint
err := toml.Unmarshal([]byte(tc.TOML), &blueprint)
if tc.Error {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotNil(t, blueprint.Customizations)
assert.Len(t, blueprint.Customizations.Files, len(tc.Want))
assert.EqualValues(t, tc.Want, blueprint.Customizations.Files)
}
})
}
}
func TestFileCustomizationUnmarshalJSON(t *testing.T) {
testCases := []struct {
Name string
JSON string
Want []FileCustomization
Error bool
}{
{
Name: "file-with-path",
JSON: `
{
"name": "test",
"description": "Test",
"version": "0.0.0",
"customizations": {
"files": [
{
"path": "/etc/file"
}
]
}
}`,
Want: []FileCustomization{
{
Path: "/etc/file",
},
},
},
{
Name: "multiple-files",
JSON: `
{
"name": "test",
"description": "Test",
"version": "0.0.0",
"customizations": {
"files": [
{
"path": "/etc/file1",
"mode": "0600",
"user": "root",
"group": "root",
"data": "hello world"
},
{
"path": "/etc/file2",
"mode": "0644",
"data": "hello world 2"
},
{
"path": "/etc/file3",
"user": 0,
"group": 0,
"data": "hello world 3"
}
]
}
}`,
Want: []FileCustomization{
{
Path: "/etc/file1",
Mode: "0600",
User: "root",
Group: "root",
Data: "hello world",
},
{
Path: "/etc/file2",
Mode: "0644",
Data: "hello world 2",
},
{
Path: "/etc/file3",
User: int64(0),
Group: int64(0),
Data: "hello world 3",
},
},
},
{
Name: "invalid-files",
JSON: `
{
"name": "test",
"description": "Test",
"version": "0.0.0",
"customizations": {
"files": [
{
"path": "/etc/../file1"
},
{
"path": "/etc/file2",
"mode": "12345"
},
{
"path": "/etc/file3",
"user": "r@@t"
},
{
"path": "/etc/file4",
"group": "r@@t"
},
{
"path": "/etc/file5",
"user": -1
},
{
"path": "/etc/file6",
"group": -1
}
]
}
}`,
Error: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
var blueprint Blueprint
err := json.Unmarshal([]byte(tc.JSON), &blueprint)
if tc.Error {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotNil(t, blueprint.Customizations)
assert.Len(t, blueprint.Customizations.Files, len(tc.Want))
assert.EqualValues(t, tc.Want, blueprint.Customizations.Files)
}
})
}
}