package limatmpl
import (
"fmt"
"os"
"reflect"
"strings"
"testing"
"github.com/sirupsen/logrus"
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/limayaml"
)
type embedTestCase struct {
description string
template string
base string
expected string
}
var embedTestCases = []embedTestCase{
{
"Empty template",
"",
"vmType: qemu",
"vmType: qemu",
},
{
"Base doesn't override existing values",
"vmType: vz",
"{arch: aarch64, vmType: qemu}",
"{arch: aarch64, vmType: vz}",
},
{
"Comments are copied over as well",
`#
# VM Type is QEMU
vmType: qemu # QEMU
`,
`
# Arch is x86_64
arch: x86_64 # X86
`,
`
# VM Type is QEMU
vmType: qemu # QEMU
# Arch is x86_64
arch: x86_64 # X86
`,
},
{
"mountTypesUnsupported are concatenated and duplicates removed",
"mountTypesUnsupported: [9p,reverse-sshfs]",
"mountTypesUnsupported: [9p,virtiofs]",
"mountTypesUnsupported: [9p,reverse-sshfs,virtiofs]",
},
{
"minimumLimaVersion (including comments) is updated when the base version is higher",
`#
# Works with Lima 0.8.0 and later
minimumLimaVersion: 0.8.0 # needs 0.8.0
`,
`
# Requires at least 1.0.2
minimumLimaVersion: 1.0.2 # or later
`,
`
# Requires at least 1.0.2
minimumLimaVersion: 1.0.2 # or later
`,
},
{
"vmOpts.qmu.minimumVersion is updated when the base version is higher",
"vmOpts: {qemu: {minimumVersion: 8.2.1}}",
"vmOpts: {qemu: {minimumVersion: 9.1.0}}",
"vmOpts: {qemu: {minimumVersion: 9.1.0}}",
},
{
"dns list is not appended, but the highest priority one is picked",
"dns: [1.1.1.1]",
"dns: [8.8.8.8, 1.2.3.4]",
"dns: [1.1.1.1]",
},
{
"Update comments on existing maps and lists that don't have comments yet",
`#
additionalDisks:
- name: disk1 # One
`,
`
# Mount additional disks
additionalDisks: # comment
# This is disk2
- name: disk2 # Two
`,
`
# Mount additional disks
additionalDisks: # comment
- name: disk1 # One
# This is disk2
- name: disk2 # Two
`,
},
{
"probes and provision scripts are prepended instead of appended",
"probes: [{script: 1}]\nprovision: [{script: One}]",
"probes: [{script: 2}]\nprovision: [{script: Two}]",
"probes: [{script: 2},{script: 1}]\nprovision: [{script: Two},{script: One}]",
},
{
"additionalDisks append, but merge fields on shared name",
"additionalDisks: [{name: disk1}]",
"additionalDisks: [{name: disk2},{name: disk1, format: true}]",
"additionalDisks: [{name: disk1, format: true},{name: disk2}]",
},
{
"TODO mounts append, but merge fields on shared mountPoint",
`#
# My mounts
mounts:
- location: loc1 # mountPoint loc1
- location: loc1
mountPoint: loc2
`,
`
mounts:
# will update mountPoint loc2
- location: loc1
mountPoint: loc2
writable: true
# SSHFS
sshfs: # ssh
followSymlinks: true
# will create new mountPoint loc3
- location: loc1
mountPoint: loc3
writable: true
`,
`
# My mounts
mounts:
- location: loc1 # mountPoint loc1
# will update mountPoint loc2
- location: loc1
mountPoint: loc2
writable: true
# SSHFS
sshfs: # ssh
followSymlinks: true
# will create new mountPoint loc3
- location: loc1
mountPoint: loc3
writable: true
`,
},
{
"mounts append, but merge fields on shared mountPoint (no comments version)",
`mounts: [{location: loc1}, {location: loc1, mountPoint: loc2}]`,
`mounts: [{location: loc1, mountPoint: loc2, writable: true, sshfs: {followSymlinks: true}}, {location: loc1, mountPoint: loc3, writable: true}]`,
`mounts: [{location: loc1}, {location: loc1, mountPoint: loc2, writable: true, sshfs: {followSymlinks: true}}, {location: loc1, mountPoint: loc3, writable: true}]`,
},
{
"template: URLs are not embedded when embedAll is false",
``,
`
base: template:default
provision:
- file:
url: template:provision.sh
probes:
- file:
url: template:probe.sh
`,
`
base: template:default
provision:
- file: template:provision.sh
probes:
- file: template:probe.sh
`,
},
{
"ERROR Each template must only be embedded once",
`#
arch: aarch64
`,
`
base: base0.yaml
# failure would mean this test loops forever, not that it fails the test
vmType: qemu
`,
`base template loop detected`,
},
{
"ERROR All bases following template: bases must be template: URLs too when embedAll is false",
``,
`base: [template:default, base1.yaml]`,
"after not embedding",
},
{
"ERROR All bases following template: bases must be template: URLs too when embedAll is false",
``,
`
base: [base1.yaml, base2.yaml]
---
base: template:default
---
base: baseX.yaml`,
"after not embedding",
},
{
"Bases are embedded depth-first",
`#`,
`
base: [base1.yaml, {url: base2.yaml}] # also test file.url format
additionalDisks: [disk0]
---
base: base3.yaml
additionalDisks: [disk1]
---
additionalDisks: [disk2]
---
additionalDisks: [disk3]
`,
`
additionalDisks: [disk0, disk1, disk3, disk2]
`,
},
{
"additionalDisks with name '*' are merged with all previous entries",
`
additionalDisks:
- name: disk1
- name: disk2
- name: disk3
format: false
`,
`
additionalDisks:
- name: disk4
- name: "*"
format: true # will apply to disk1, disk2, and disk4
- name: disk5
`,
`
additionalDisks:
- name: disk1
format: true
- name: disk2
format: true
- name: disk3
format: false
- name: disk4
format: true
- name: disk5
`,
},
{
"TODO additionalDisks will be upgraded from string to map",
`#
additionalDisks:
# my head comment
- mine # my line comment
`,
`
# head comment
additionalDisks: # line comment
- name: "*"
format: true # formatting is good for you
`,
`
# head comment
additionalDisks: # line comment
# my head comment
- name: mine # my line comment
format: true # formatting is good for you
`,
},
{
"additionalDisks will be upgraded from string to map (no comments version)",
`additionalDisks: [mine]`,
`additionalDisks: [{name: "*", format: true}]`,
`additionalDisks: [{name: mine, format: true}]`,
},
{
"networks without interface name are not merged",
`
networks:
- interface: lima1
`,
`
networks:
- interface: lima2
# The metric will not be merged with anything
- metric: 250
- interface: lima1
metric: 100 # will be set on the first entry
- interface: '*' # wildcard
metric: 123 # will be set on the first entry
`,
`
networks:
- interface: lima1
metric: 100 # will be set on the first entry
- interface: lima2
metric: 123 # will be set on the first entry
# The metric will not be merged with anything
- metric: 250
`,
},
{
"Scripts are embedded with comments moved",
`#
# Hi There!
provision:
# This script will be merged from an external file
- file: base1.sh # This comment will move to the "script" key
# This is just a data file
- mode: data
file: base1.sh # This comment will move to the "content" key
path: /tmp/data
- mode: yq
file: base1.sh # This comment will move to the "expression" key
path: /tmp/yq
`,
`
# base0.yaml is ignored
---
#!/usr/bin/env bash
echo "This is base1.sh"
`,
`
# Hi There!
provision:
# This script will be merged from an external file
- script: |- # This comment will move to the "script" key
#!/usr/bin/env bash
echo "This is base1.sh"
# This is just a data file
- mode: data
content: |- # This comment will move to the "content" key
#!/usr/bin/env bash
echo "This is base1.sh"
path: /tmp/data
- mode: yq
expression: |- # This comment will move to the "expression" key
#!/usr/bin/env bash
echo "This is base1.sh"
path: /tmp/yq
# base0.yaml is ignored
`,
},
{
"Script files are embedded even when no base property exists",
"provision: [{file: base0.sh}]",
"#! my script",
`provision: [{script: "#! my script"}]`,
},
{
"ERROR base digest is not yet implemented",
"",
"base: [{url: base.yaml, digest: deafbad}]",
"not yet implemented",
},
{
"Image URLs will be converted into a template",
"",
"base: https://example.com/lima-linux-riscv64.img",
"{arch: riscv64, images: [{location: https://example.com/lima-linux-riscv64.img, arch: riscv64}]}",
},
{
"Binary files are base64 encoded",
`#
provision:
- mode: data
file: base1.sh # This comment will move to the "content" key
path: /tmp/data
`,
"# base0.yaml is ignored\n---\n#!\a123456789012345678901234567890123456789012345678901234567890",
`
provision:
- mode: data
content: !!binary | # This comment will move to the "content" key
IyEHMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0
NTY3ODkw
path: /tmp/data
# base0.yaml is ignored
`,
},
}
func TestEmbed(t *testing.T) {
focus := os.Getenv("TEST_FOCUS")
for _, tc := range embedTestCases {
if focus != "" {
if !strings.Contains(tc.description, focus) {
continue
}
logrus.SetLevel(logrus.DebugLevel)
}
t.Run(tc.description, func(t *testing.T) { RunEmbedTest(t, tc) })
}
logrus.SetLevel(logrus.InfoLevel)
}
func RunEmbedTest(t *testing.T, tc embedTestCase) {
todo := strings.HasPrefix(tc.description, "TODO")
expectError := strings.HasPrefix(tc.description, "ERROR")
stringCompare := strings.HasPrefix(tc.template, "#")
tc.template = strings.TrimSpace(strings.TrimPrefix(tc.template, "#"))
tc.base = strings.TrimSpace(tc.base)
tc.expected = strings.TrimSpace(tc.expected)
t.Chdir(t.TempDir())
for i, base := range strings.Split(tc.base, "---\n") {
extension := ".yaml"
if strings.HasPrefix(base, "#!") {
extension = ".sh"
}
baseFilename := fmt.Sprintf("base%d%s", i, extension)
err := os.WriteFile(baseFilename, []byte(base), 0o600)
assert.NilError(t, err, tc.description)
}
tmpl := &Template{
Bytes: fmt.Appendf(nil, "base: base0.yaml\n%s", tc.template),
Locator: "tmpl.yaml",
}
if strings.HasPrefix(tc.base, "#!") {
tmpl.Bytes = []byte(tc.template)
}
err := tmpl.Embed(t.Context(), false, false)
if expectError {
assert.ErrorContains(t, err, tc.expected, tc.description)
return
}
assert.NilError(t, err, tc.description)
if stringCompare {
actual := strings.TrimSpace(string(tmpl.Bytes))
if todo {
assert.Assert(t, actual != tc.expected, tc.description)
} else {
assert.Equal(t, actual, tc.expected, tc.description)
}
return
}
err = tmpl.Unmarshal()
assert.NilError(t, err, tc.description)
var expected limatype.LimaYAML
err = limayaml.Unmarshal([]byte(tc.expected), &expected, "expected")
assert.NilError(t, err, tc.description)
if todo {
assert.Assert(t, !reflect.DeepEqual(tmpl.Config, &expected), tc.description)
} else {
assert.Assert(t, cmp.DeepEqual(tmpl.Config, &expected), tc.description)
}
}
func TestEncodeScriptReason(t *testing.T) {
maxLineLength = 8
t.Run("regular script", func(t *testing.T) {
reason := encodeScriptReason("0123456\n")
assert.Equal(t, reason, "")
})
t.Run("binary script", func(t *testing.T) {
reason := encodeScriptReason("abc\a123")
assert.Equal(t, reason, "unprintable character '\\a' at offset 3")
})
t.Run("contains a tab character", func(t *testing.T) {
reason := encodeScriptReason("foo\tbar")
assert.Equal(t, reason, "unprintable character '\\t' at offset 3")
})
t.Run("long line", func(t *testing.T) {
reason := encodeScriptReason("line 1\nline 2\n01234567\n")
assert.Equal(t, reason, "line 3 (offset 14) is longer than 8 characters")
})
}