blob: 5aeeccdcb612d0b2fbd596fa02ab165558fde726 [file] [log] [blame]
// Copyright 2020 The Monogon Project Authors.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This package provides self-contained implementation used to generate
// Metropolis disk images.
package osimage
import (
"fmt"
"io"
"os"
diskfs "github.com/diskfs/go-diskfs"
"github.com/diskfs/go-diskfs/disk"
"github.com/diskfs/go-diskfs/filesystem"
"github.com/diskfs/go-diskfs/partition/gpt"
"github.com/google/uuid"
"source.monogon.dev/metropolis/pkg/efivarfs"
)
const (
systemPartitionType = gpt.Type("ee96055b-f6d0-4267-8bbb-724b2afea74c")
SystemVolumeLabel = "METROPOLIS-SYSTEM"
dataPartitionType = gpt.Type("9eeec464-6885-414a-b278-4305c51f7966")
DataVolumeLabel = "METROPOLIS-NODE-DATA"
ESPVolumeLabel = "ESP"
EFIPayloadPath = "/EFI/BOOT/BOOTx64.EFI"
nodeParamsPath = "/EFI/metropolis/parameters.pb"
mib = 1024 * 1024
)
// put creates a file on the target filesystem fs and fills it with
// contents read from an io.Reader object src.
func put(fs filesystem.FileSystem, dst string, src io.Reader) error {
target, err := fs.OpenFile(dst, os.O_CREATE|os.O_RDWR)
if err != nil {
return fmt.Errorf("while opening %q: %w", dst, err)
}
// If this is streamed (e.g. using io.Copy) it exposes a bug in diskfs, so
// do it in one go.
// TODO(mateusz@monogon.tech): Investigate the bug.
data, err := io.ReadAll(src)
if err != nil {
return fmt.Errorf("while reading %q: %w", src, err)
}
if _, err := target.Write(data); err != nil {
return fmt.Errorf("while writing to %q: %w", dst, err)
}
return nil
}
// initializeESP creates an ESP filesystem in a partition specified by
// index. It then creates the EFI executable and copies into it contents
// of the reader object exec, which must not be nil. The node parameters
// file is optionally created if params is not nil. initializeESP may return
// an error.
func initializeESP(image *disk.Disk, index int, exec, params io.Reader) error {
// Create a FAT ESP filesystem inside a partition pointed to by
// index.
spec := disk.FilesystemSpec{
Partition: index,
FSType: filesystem.TypeFat32,
VolumeLabel: ESPVolumeLabel,
}
fs, err := image.CreateFilesystem(spec)
if err != nil {
return fmt.Errorf("while creating an ESP filesystem: %w", err)
}
// Create the EFI partition structure.
for _, dir := range []string{"/EFI", "/EFI/BOOT", "/EFI/metropolis"} {
if err := fs.Mkdir(dir); err != nil {
return fmt.Errorf("while creating %q: %w", dir, err)
}
}
// Copy the EFI payload to the newly created filesystem.
if exec == nil {
return fmt.Errorf("exec must not be nil")
}
if err := put(fs, EFIPayloadPath, exec); err != nil {
return fmt.Errorf("while writing an EFI payload: %w", err)
}
if params != nil {
// Copy Node Parameters into the ESP.
if err := put(fs, nodeParamsPath, params); err != nil {
return fmt.Errorf("while writing node parameters: %w", err)
}
}
return nil
}
// zeroSrc is a source of null bytes implementing io.Reader. It acts as a
// helper data type.
type zeroSrc struct{}
// Read implements io.Reader for zeroSrc. It fills b with zero bytes. The
// returned error is always nil.
func (_ zeroSrc) Read(b []byte) (n int, err error) {
for i := range b {
b[i] = 0
}
return len(b), nil
}
// initializeSystemPartition copies system partition contents into a partition
// at index. The remaining partition space is zero-padded. This function may
// return an error.
func initializeSystemPartition(image *disk.Disk, index int, contents io.Reader) error {
// Check the parameters.
if contents == nil {
return fmt.Errorf("system partition contents parameter must not be nil")
}
if index <= 0 {
return fmt.Errorf("partition index must be greater than zero")
}
// Get the system partition's size.
table, err := image.GetPartitionTable()
if err != nil {
return fmt.Errorf("while accessing a go-diskfs partition table: %w", err)
}
partitions := table.GetPartitions()
if index > len(partitions) {
return fmt.Errorf("partition index out of bounds")
}
size := partitions[index-1].GetSize()
// Copy the contents of the Metropolis system image into the system partition
// at partitionIndex. Zero-pad the remaining space.
var zero zeroSrc
sys := io.LimitReader(io.MultiReader(contents, zero), size)
if _, err := image.WritePartitionContents(index, sys); err != nil {
return fmt.Errorf("while copying the system partition: %w", err)
}
return nil
}
// mibToSectors converts a size expressed in mebibytes to a number of
// sectors needed to store data of that size. sectorSize parameter
// specifies the size of a logical sector.
func mibToSectors(size, sectorSize uint64) uint64 {
return (size * mib) / sectorSize
}
// PartitionSizeInfo contains parameters used during partition table
// initialization and, in case of image files, space allocation.
type PartitionSizeInfo struct {
// Size of the EFI System Partition (ESP), in mebibytes. The size must
// not be zero.
ESP uint64
// Size of the Metropolis system partition, in mebibytes. The partition
// won't be created if the size is zero.
System uint64
// Size of the Metropolis data partition, in mebibytes. The partition
// won't be created if the size is zero. If the image is output to a
// block device, the partition will be extended to fill the remaining
// space.
Data uint64
}
// partitionList stores partition definitions in an ascending order.
type partitionList []*gpt.Partition
// appendPartition puts a new partition at the end of a partitionList,
// automatically calculating its start and end markers based on data in
// the list and the argument psize. A partition type and a name are
// assigned to the partition. The containing image is used to calculate
// sector addresses based on its logical block size.
func (pl *partitionList) appendPartition(image *disk.Disk, ptype gpt.Type, pname string, psize uint64) {
// Calculate the start and end marker.
var pstart, pend uint64
if len(*pl) != 0 {
pstart = (*pl)[len(*pl)-1].End + 1
} else {
pstart = mibToSectors(1, uint64(image.LogicalBlocksize))
}
pend = pstart + mibToSectors(psize, uint64(image.LogicalBlocksize)) - 1
// Put the new partition at the end of the list.
*pl = append(*pl, &gpt.Partition{
Type: ptype,
Name: pname,
Start: pstart,
End: pend,
})
}
// extendLastPartition moves the end marker of the last partition in a
// partitionList to the end of image, assigning all of the remaining free
// space to it. It may return an error.
func (pl *partitionList) extendLastPartition(image *disk.Disk) error {
if len(*pl) == 0 {
return fmt.Errorf("no partitions defined")
}
if image.Size == 0 {
return fmt.Errorf("the image size mustn't be zero")
}
if image.LogicalBlocksize == 0 {
return fmt.Errorf("the image's logical block size mustn't be zero")
}
// The last 33 blocks are occupied by the Secondary GPT.
(*pl)[len(*pl)-1].End = uint64(image.Size/image.LogicalBlocksize) - 33
return nil
}
// initializePartitionTable applies a Metropolis-compatible GPT partition
// table to an image. Logical and physical sector sizes are based on
// block sizes exposed by Disk. Partition extents are defined by the size
// argument. A diskGUID is associated with the partition table. In an event
// the table couldn't have been applied, the function will return an error.
func initializePartitionTable(image *disk.Disk, size *PartitionSizeInfo, diskGUID string) error {
// Start with preparing a partition list.
var pl partitionList
// Create the ESP.
if size.ESP == 0 {
return fmt.Errorf("ESP size mustn't be zero")
}
pl.appendPartition(image, gpt.EFISystemPartition, ESPVolumeLabel, size.ESP)
// Create the system partition only if its size is specified.
if size.System != 0 {
pl.appendPartition(image, systemPartitionType, SystemVolumeLabel, size.System)
}
// Create the data partition only if its size is specified.
if size.Data != 0 {
// Don't specify the last partition's length, as it will be extended
// to fit the image size anyway. size.Data will still be used in the
// space allocation step.
pl.appendPartition(image, dataPartitionType, DataVolumeLabel, 0)
if err := pl.extendLastPartition(image); err != nil {
return fmt.Errorf("while extending the last partition: %w", err)
}
}
// Build and apply the partition table.
table := &gpt.Table{
LogicalSectorSize: int(image.LogicalBlocksize),
PhysicalSectorSize: int(image.PhysicalBlocksize),
ProtectiveMBR: true,
GUID: diskGUID,
Partitions: pl,
}
if err := image.Partition(table); err != nil {
// Return the error unwrapped.
return err
}
return nil
}
// Params contains parameters used by Create to build a Metropolis OS
// image.
type Params struct {
// OutputPath is the path an OS image will be written to. If the path
// points to an existing block device, the data partition will be
// extended to fill it entirely. Otherwise, a regular image file will
// be created at OutputPath. The path must not point to an existing
// regular file.
OutputPath string
// EFIPayload provides contents of the EFI payload file. It must not be
// nil.
EFIPayload io.Reader
// SystemImage provides contents of the Metropolis system partition.
// If nil, no contents will be copied into the partition.
SystemImage io.Reader
// NodeParameters provides contents of the node parameters file. If nil,
// the node parameters file won't be created in the target ESP
// filesystem.
NodeParameters io.Reader
// DiskGUID is a unique identifier of the image and a part of GPT
// header. It's optional and can be left blank if the identifier is
// to be randomly generated. Setting it to a predetermined value can
// help in implementing reproducible builds.
DiskGUID string
// PartitionSize specifies a size for the ESP, Metropolis System and
// Metropolis data partition.
PartitionSize PartitionSizeInfo
}
// Create writes a Metropolis OS image to either a newly created regular
// file or a block device. The image is parametrized by the params
// argument. In case a regular file already exists at params.OutputPath,
// the function will fail. It returns nil on success or an error, if one
// did occur.
func Create(params *Params) (*efivarfs.BootEntry, error) {
// Validate each parameter before use.
if params.OutputPath == "" {
return nil, fmt.Errorf("image output path must be set")
}
// Learn whether we're creating a new image or writing to an existing
// block device by stat-ing the output path parameter.
outInfo, err := os.Stat(params.OutputPath)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
// Calculate the image size (bytes) by summing up partition sizes
// (mebibytes).
minSize := (int64(params.PartitionSize.ESP) +
int64(params.PartitionSize.System) +
int64(params.PartitionSize.Data) + 1) * mib
var diskImg *disk.Disk
if !os.IsNotExist(err) && outInfo.Mode()&os.ModeDevice != 0 {
// Open the block device. The data partition size parameter won't
// matter in this case, as said partition will be extended till the
// end of device.
diskImg, err = diskfs.Open(params.OutputPath)
if err != nil {
return nil, fmt.Errorf("couldn't open the block device at %q: %w", params.OutputPath, err)
}
// Make sure there's at least minSize space available on the block
// device.
if minSize > diskImg.Size {
return nil, fmt.Errorf("not enough space available on the block device at %q", params.OutputPath)
}
} else {
// Attempt to create an image file at params.OutputPath. diskfs.Create
// will abort in case a file already exists at the given path.
// Calculate the image size expressed in bytes by summing up declared
// partition lengths.
diskImg, err = diskfs.Create(params.OutputPath, minSize, diskfs.Raw)
if err != nil {
return nil, fmt.Errorf("couldn't create a disk image at %q: %w", params.OutputPath, err)
}
}
// Go through the initialization steps, starting with applying a
// partition table according to params.
if err := initializePartitionTable(diskImg, &params.PartitionSize, params.DiskGUID); err != nil {
return nil, fmt.Errorf("failed to initialize the partition table: %w", err)
}
// The system partition will be created only if its specified size isn't
// equal to zero, making the initialization step optional as well. In
// addition, params.SystemImage must be set.
if params.PartitionSize.System != 0 && params.SystemImage != nil {
if err := initializeSystemPartition(diskImg, 2, params.SystemImage); err != nil {
return nil, fmt.Errorf("failed to initialize the system partition: %w", err)
}
} else if params.PartitionSize.System == 0 && params.SystemImage != nil {
// Safeguard against contradicting parameters.
return nil, fmt.Errorf("the system image parameter was passed while the associated partition size is zero")
}
// Attempt to initialize the ESP unconditionally, as it's the only
// partition guaranteed to be created regardless of params.PartitionSize.
if err := initializeESP(diskImg, 1, params.EFIPayload, params.NodeParameters); err != nil {
return nil, fmt.Errorf("failed to initialize the ESP: %w", err)
}
// The data partition, even if created, is always left uninitialized.
// Build an EFI boot entry pointing to the image's ESP. go-diskfs won't let
// you do that after you close the image.
t, err := diskImg.GetPartitionTable()
p := t.GetPartitions()
esp := (p[0]).(*gpt.Partition)
guid, err := uuid.Parse(esp.GUID)
if err != nil {
return nil, fmt.Errorf("couldn't parse the GPT GUID: %w", err)
}
be := efivarfs.BootEntry{
Description: "Metropolis",
Path: EFIPayloadPath,
PartitionGUID: guid,
PartitionNumber: 1,
PartitionStart: esp.Start,
PartitionSize: esp.End - esp.Start + 1,
}
// Close the image and return the EFI boot entry.
if err := diskImg.File.Close(); err != nil {
return nil, fmt.Errorf("failed to finalize image: %w", err)
}
return &be, nil
}