a73x

internal/cloudinit/append_test.go

Ref:   Size: 4.2 KiB   History

package cloudinit

import (
	"io"
	"mime"
	"mime/multipart"
	"net/mail"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// eitriDoc stands in for the cloud-config the agent's seed appends. Its
// merge_how line is what the ordering below exists to serve.
const eitriDoc = "#cloud-config\nmerge_how: \"list(prepend)+dict(recurse_array,no_replace)+str()\"\nruncmd:\n  - [\"sh\", \"-c\", \"resize2fs /dev/vda1\"]\n"

// orderedParts parses an archive into (contentType, body) pairs IN ORDER.
// mimeParts keys by content type, which cannot see the difference between two
// cloud-config parts — and their order is the whole contract here.
func orderedParts(t *testing.T, s string) []struct{ CT, Body string } {
	t.Helper()
	msg, err := mail.ReadMessage(strings.NewReader(s))
	require.NoError(t, err)
	mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
	require.NoError(t, err)
	require.Equal(t, "multipart/mixed", mediaType)
	mr := multipart.NewReader(msg.Body, params["boundary"])
	var out []struct{ CT, Body string }
	for {
		p, err := mr.NextPart()
		if err != nil {
			break
		}
		body, err := io.ReadAll(p)
		require.NoError(t, err)
		mt, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
		out = append(out, struct{ CT, Body string }{mt, string(body)})
	}
	return out
}

func TestAppendCloudConfigPutsTheTenantsDocumentFirstAndEitrisLast(t *testing.T) {
	// Order is the contract: cloud-init's cloud-config handler merges each part
	// into the buffer the earlier parts built, using THAT part's mergers. eitri's
	// part must therefore be merged in last, or its merge_how governs nothing.
	tenant := "#cloud-config\npackages: [htop]\nruncmd:\n  - [\"sh\", \"-c\", \"echo tenant\"]\n"
	out, err := AppendCloudConfig(tenant, eitriDoc)
	require.NoError(t, err)

	parts := orderedParts(t, out)
	require.Len(t, parts, 2)
	assert.Equal(t, "text/cloud-config", parts[0].CT)
	assert.Equal(t, tenant, parts[0].Body, "the tenant's document rides byte-for-byte")
	assert.Equal(t, "text/cloud-config", parts[1].CT)
	assert.Equal(t, eitriDoc, parts[1].Body, "eitri's document is the last part merged")
}

func TestAppendCloudConfigLeavesANonCloudConfigPayloadIntact(t *testing.T) {
	// A tenant script is not something eitri can merge into, so it rides as its
	// own part under the type cloud-init dispatches it with, untouched.
	script := "#!/bin/bash\necho hello > /tmp/marker\n"
	out, err := AppendCloudConfig(script, eitriDoc)
	require.NoError(t, err)

	parts := orderedParts(t, out)
	require.Len(t, parts, 2)
	assert.Equal(t, "text/x-shellscript", parts[0].CT)
	assert.Equal(t, script, parts[0].Body)
	assert.Equal(t, "text/cloud-config", parts[1].CT)
	assert.Equal(t, eitriDoc, parts[1].Body)
}

func TestAppendCloudConfigAppendsToAnExistingArchiveRatherThanNesting(t *testing.T) {
	existing, err := wrapMultipart([]part{
		typedPart("text/cloud-config", "#cloud-config\npackages:\n  - git\n"),
		typedPart("text/x-shellscript", "#!/bin/bash\necho hi\n"),
	})
	require.NoError(t, err)

	out, err := AppendCloudConfig(existing, eitriDoc)
	require.NoError(t, err)

	parts := orderedParts(t, out)
	require.Len(t, parts, 3)
	for _, p := range parts {
		assert.NotContains(t, p.CT, "multipart", "must append, not nest")
	}
	assert.Equal(t, "#cloud-config\npackages:\n  - git\n", parts[0].Body, "the tenant's own parts keep their order")
	assert.Equal(t, "#!/bin/bash\necho hi\n", parts[1].Body)
	assert.Equal(t, eitriDoc, parts[2].Body, "eitri's part is last, after every part the tenant brought")
}

func TestAppendCloudConfigRefusesAPayloadItCannotGiveAPartHeader(t *testing.T) {
	// A jinja template's real type is unknown until it renders and a gzip blob is
	// opaque, so neither can be labelled honestly. Refusing is the only safe
	// answer: composing them wrong would hand the guest a seed whose CA trust
	// never lands, which is the failure this whole mechanism exists to prevent.
	for _, in := range []string{
		"## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n",
		"\x1f\x8b\x08 gzipped",
		"random text that is not user-data",
	} {
		_, err := AppendCloudConfig(in, eitriDoc)
		assert.Error(t, err, "must refuse %q rather than drop eitri's config", in[:min(20, len(in))])
	}
}