Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 63 additions & 7 deletions tools/labctl/internal/adapters/nocloudcidata/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,21 @@ import (
)

const (
cidataSizeBytes = 16 * 1024 * 1024
mibibyte = 1024 * 1024
// cidataMinSize is the floor used by Builder. FAT32 needs at least this
// many bytes to format reliably with the diskfs defaults.
cidataMinSize int64 = 16 * mibibyte
// CidataMaxSize bounds the cidata image. A Talos machine config larger
// than the resulting payload limit is a strong signal that the operator
// is embedding something that should live in a side channel rather than
// on a NoCloud cidata disk.
CidataMaxSize int64 = 64 * mibibyte
// cidataOverhead is a generous reserve for FAT32 tables, reserved
// sectors, and cluster alignment so that small payloads always fit.
cidataOverhead int64 = 4 * mibibyte

volumeLabel = "CIDATA"
cidataImageMode = 0o600
)

// Builder writes NoCloud cidata disk images.
Expand All @@ -25,16 +38,34 @@ func New() Builder {
}

// Build writes a FAT32 NoCloud cidata image to path.
//
// The image is sized to fit the payload with FAT32 overhead, capped at
// [CidataMaxSize]; payloads that would require a larger image surface a
// clear error rather than the diskfs library's lower-level failure. The
// output file is set to mode 0600 because Talos machine configuration
// embedded in user-data is secret material — it carries cluster PKI and
// API credentials and must be protected accordingly.
func (Builder) Build(path string, payload talosimage.ConfigDiskPayload) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
size, err := sizeFor(payload)
if err != nil {
return err
}

err = os.Remove(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove existing cidata image %q: %w", path, err)
}

diskImage, err := diskfs.Create(path, cidataSizeBytes, diskfs.SectorSize512)
diskImage, err := diskfs.Create(path, size, diskfs.SectorSize512)
if err != nil {
return fmt.Errorf("create cidata image %q: %w", path, err)
}

err = os.Chmod(path, cidataImageMode)
if err != nil {
return fmt.Errorf("set cidata image %q mode: %w", path, err)
}

cidata, err := diskImage.CreateFilesystem(disk.FilesystemSpec{
Partition: 0,
FSType: filesystem.TypeFat32,
Expand All @@ -46,29 +77,54 @@ func (Builder) Build(path string, payload talosimage.ConfigDiskPayload) error {
}
defer cidata.Close()

if err := writeFile(cidata, "/user-data", payload.UserData); err != nil {
err = writeFile(cidata, "/user-data", payload.UserData)
if err != nil {
return err
}
if err := writeFile(cidata, "/meta-data", payload.MetaData); err != nil {
err = writeFile(cidata, "/meta-data", payload.MetaData)
if err != nil {
return err
}
if len(payload.NetworkConfig) > 0 {
if err := writeFile(cidata, "/network-config", payload.NetworkConfig); err != nil {
err = writeFile(cidata, "/network-config", payload.NetworkConfig)
if err != nil {
return err
}
}

return nil
}

func sizeFor(payload talosimage.ConfigDiskPayload) (int64, error) {
payloadSize := int64(len(payload.UserData) + len(payload.MetaData) + len(payload.NetworkConfig))

want := roundUpMiB(max(payloadSize+cidataOverhead, cidataMinSize))

if want > CidataMaxSize {
return 0, fmt.Errorf(
"cidata payload (%d bytes) requires %d bytes which exceeds the maximum cidata image size of %d bytes",
payloadSize,
want,
CidataMaxSize,
)
}

return want, nil
}

func roundUpMiB(n int64) int64 {
return ((n + mibibyte - 1) / mibibyte) * mibibyte
}

func writeFile(cidata filesystem.FileSystem, path string, data []byte) error {
file, err := cidata.OpenFile(path, os.O_CREATE|os.O_RDWR)
if err != nil {
return fmt.Errorf("create cidata file %q: %w", path, err)
}
defer file.Close()

if _, err := file.Write(data); err != nil {
_, err = file.Write(data)
if err != nil {
return fmt.Errorf("write cidata file %q: %w", path, err)
}

Expand Down
56 changes: 55 additions & 1 deletion tools/labctl/internal/adapters/nocloudcidata/builder_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package nocloudcidata_test

import (
"bytes"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"

Expand All @@ -14,7 +17,7 @@ import (
)

func TestBuilderWritesNoCloudCIDATAImage(t *testing.T) {
path := t.TempDir() + "/cidata.img"
path := filepath.Join(t.TempDir(), "cidata.img")
payload := talosimage.ConfigDiskPayload{
UserData: []byte("machine:\n type: controlplane\n"),
MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"),
Expand All @@ -36,6 +39,57 @@ func TestBuilderWritesNoCloudCIDATAImage(t *testing.T) {
assert.Equal(t, payload.NetworkConfig, readFile(t, cidata, "/network-config"))
}

func TestBuilderSetsImageMode0600(t *testing.T) {
path := filepath.Join(t.TempDir(), "cidata.img")

err := nocloudcidata.New().Build(path, talosimage.ConfigDiskPayload{
UserData: []byte("machine:\n type: controlplane\n"),
MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"),
})

require.NoError(t, err)
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o600), info.Mode().Perm(), "cidata image must be 0600")
}

func TestBuilderHandlesPayloadLargerThanDefaultFloor(t *testing.T) {
path := filepath.Join(t.TempDir(), "cidata.img")
bigUserData := bytes.Repeat([]byte("a"), 32*1024*1024)

err := nocloudcidata.New().Build(path, talosimage.ConfigDiskPayload{
UserData: bigUserData,
MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"),
})

require.NoError(t, err)

diskImage, err := diskfs.Open(path, diskfs.WithOpenMode(diskfs.ReadOnly))
require.NoError(t, err)
cidata, err := diskImage.GetFilesystem(0)
require.NoError(t, err)
defer cidata.Close()

got := readFile(t, cidata, "/user-data")
assert.Equal(t, bigUserData, got)
}

func TestBuilderRejectsPayloadOverMaxSize(t *testing.T) {
path := filepath.Join(t.TempDir(), "cidata.img")
overflow := bytes.Repeat([]byte("a"), int(nocloudcidata.CidataMaxSize)+1)

err := nocloudcidata.New().Build(path, talosimage.ConfigDiskPayload{
UserData: overflow,
MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"),
})

require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds the maximum cidata image size")

_, statErr := os.Stat(path)
assert.True(t, os.IsNotExist(statErr), "no partial image must be left on disk")
}

func readFile(t *testing.T, fsys fs.ReadFileFS, name string) []byte {
t.Helper()

Expand Down
Loading