m/n/b/mkimage/osimage: allow planning for installations

This allows planning an installation before actually committing any
writes to the underlying blockdevice. By adding this we can ensure we
don't brick any machines/bring them into a state where only manual
intervention brings it back.

Change-Id: I5f760c8aa83669a23b7ba55ba7ea471743e9e849
Reviewed-on: https://review.monogon.dev/c/monogon/+/3212
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/node/build/mkimage/osimage/osimage.go b/metropolis/node/build/mkimage/osimage/osimage.go
index f877ded..8f7ab6f 100644
--- a/metropolis/node/build/mkimage/osimage/osimage.go
+++ b/metropolis/node/build/mkimage/osimage/osimage.go
@@ -94,85 +94,35 @@
 	PartitionSize PartitionSizeInfo
 }
 
-const Mi = 1024 * 1024
+type plan struct {
+	*Params
+	rootInode        fat32.Inode
+	tbl              *gpt.Table
+	efiPartition     *gpt.Partition
+	systemPartitionA *gpt.Partition
+	systemPartitionB *gpt.Partition
+	dataPartition    *gpt.Partition
+}
 
-// Create writes a Metropolis OS image to a block device.
-func Create(params *Params) (*efivarfs.LoadOption, error) {
+// Apply actually writes the planned installation to the blockdevice.
+func (i *plan) Apply() (*efivarfs.LoadOption, error) {
 	// Discard the entire device, we're going to write new data over it.
 	// Ignore errors, this is only advisory.
-	params.Output.Discard(0, params.Output.BlockCount()*params.Output.BlockSize())
+	i.Output.Discard(0, i.Output.BlockCount()*i.Output.BlockSize())
 
-	tbl, err := gpt.New(params.Output)
-	if err != nil {
-		return nil, fmt.Errorf("invalid block device: %w", err)
-	}
-	tbl.ID = params.DiskGUID
-	esp := gpt.Partition{
-		Type: gpt.PartitionTypeEFISystem,
-		Name: ESPLabel,
-	}
-	if err := tbl.AddPartition(&esp, params.PartitionSize.ESP*Mi); err != nil {
-		return nil, fmt.Errorf("failed to allocate ESP: %w", err)
-	}
-
-	rootInode := fat32.Inode{
-		Attrs: fat32.AttrDirectory,
-	}
-	if err := rootInode.PlaceFile(strings.TrimPrefix(EFIBootAPath, "/"), params.EFIPayload); err != nil {
-		return nil, err
-	}
-	// Place the A/B loader at the EFI bootloader autodiscovery path.
-	if err := rootInode.PlaceFile(strings.TrimPrefix(EFIPayloadPath, "/"), params.ABLoader); err != nil {
-		return nil, err
-	}
-	if params.NodeParameters != nil {
-		if err := rootInode.PlaceFile(nodeParamsPath, params.NodeParameters); err != nil {
-			return nil, err
-		}
-	}
-	if err := fat32.WriteFS(blockdev.NewRWS(esp), rootInode, fat32.Options{
-		BlockSize:  uint16(esp.BlockSize()),
-		BlockCount: uint32(esp.BlockCount()),
+	if err := fat32.WriteFS(blockdev.NewRWS(i.efiPartition), i.rootInode, fat32.Options{
+		BlockSize:  uint16(i.efiPartition.BlockSize()),
+		BlockCount: uint32(i.efiPartition.BlockCount()),
 		Label:      "MNGN_BOOT",
 	}); err != nil {
 		return nil, fmt.Errorf("failed to write FAT32: %w", err)
 	}
 
-	// Create the system partition only if its size is specified.
-	if params.PartitionSize.System != 0 && params.SystemImage != nil {
-		systemPartitionA := gpt.Partition{
-			Type: SystemAType,
-			Name: SystemALabel,
-		}
-		if err := tbl.AddPartition(&systemPartitionA, params.PartitionSize.System*Mi); err != nil {
-			return nil, fmt.Errorf("failed to allocate system partition A: %w", err)
-		}
-		if _, err := io.Copy(blockdev.NewRWS(systemPartitionA), params.SystemImage); err != nil {
-			return nil, fmt.Errorf("failed to write system partition A: %w", err)
-		}
-		systemPartitionB := gpt.Partition{
-			Type: SystemBType,
-			Name: SystemBLabel,
-		}
-		if err := tbl.AddPartition(&systemPartitionB, params.PartitionSize.System*Mi); err != nil {
-			return nil, fmt.Errorf("failed to allocate system partition B: %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")
-	}
-	// Create the data partition only if its size is specified.
-	if params.PartitionSize.Data != 0 {
-		dataPartition := gpt.Partition{
-			Type: DataType,
-			Name: DataLabel,
-		}
-		if err := tbl.AddPartition(&dataPartition, -1); err != nil {
-			return nil, fmt.Errorf("failed to allocate data partition: %w", err)
-		}
+	if _, err := io.Copy(blockdev.NewRWS(i.systemPartitionA), i.SystemImage); err != nil {
+		return nil, fmt.Errorf("failed to write system partition A: %w", err)
 	}
 
-	if err := tbl.Write(); err != nil {
+	if err := i.tbl.Write(); err != nil {
 		return nil, fmt.Errorf("failed to write Table: %w", err)
 	}
 
@@ -182,13 +132,106 @@
 		FilePath: efivarfs.DevicePath{
 			&efivarfs.HardDrivePath{
 				PartitionNumber:     1,
-				PartitionStartBlock: esp.FirstBlock,
-				PartitionSizeBlocks: esp.SizeBlocks(),
+				PartitionStartBlock: i.efiPartition.FirstBlock,
+				PartitionSizeBlocks: i.efiPartition.SizeBlocks(),
 				PartitionMatch: efivarfs.PartitionGPT{
-					PartitionUUID: esp.ID,
+					PartitionUUID: i.efiPartition.ID,
 				},
 			},
 			efivarfs.FilePath(EFIPayloadPath),
 		},
 	}, nil
 }
+
+// Plan allows to prepare an installation without modifying any data on the
+// system. To apply the planned installation, call Apply on the returned plan.
+func Plan(p *Params) (*plan, error) {
+	params := &plan{Params: p}
+
+	var err error
+	params.tbl, err = gpt.New(params.Output)
+	if err != nil {
+		return nil, fmt.Errorf("invalid block device: %w", err)
+	}
+
+	params.tbl.ID = params.DiskGUID
+	params.efiPartition = &gpt.Partition{
+		Type: gpt.PartitionTypeEFISystem,
+		Name: ESPLabel,
+	}
+
+	if err := params.tbl.AddPartition(params.efiPartition, params.PartitionSize.ESP*Mi); err != nil {
+		return nil, fmt.Errorf("failed to allocate ESP: %w", err)
+	}
+
+	params.rootInode = fat32.Inode{
+		Attrs: fat32.AttrDirectory,
+	}
+	if err := params.rootInode.PlaceFile(strings.TrimPrefix(EFIBootAPath, "/"), params.EFIPayload); err != nil {
+		return nil, err
+	}
+	// Place the A/B loader at the EFI bootloader autodiscovery path.
+	if err := params.rootInode.PlaceFile(strings.TrimPrefix(EFIPayloadPath, "/"), params.ABLoader); err != nil {
+		return nil, err
+	}
+	if params.NodeParameters != nil {
+		if err := params.rootInode.PlaceFile(nodeParamsPath, params.NodeParameters); err != nil {
+			return nil, err
+		}
+	}
+
+	// Try to layout the fat32 partition. If it detects that the disk is too
+	// small, an error will be returned.
+	if _, err := fat32.SizeFS(params.rootInode, fat32.Options{
+		BlockSize:  uint16(params.efiPartition.BlockSize()),
+		BlockCount: uint32(params.efiPartition.BlockCount()),
+		Label:      "MNGN_BOOT",
+	}); err != nil {
+		return nil, fmt.Errorf("failed to calculate size of FAT32: %w", err)
+	}
+
+	// Create the system partition only if its size is specified.
+	if params.PartitionSize.System != 0 && params.SystemImage != nil {
+		params.systemPartitionA = &gpt.Partition{
+			Type: SystemAType,
+			Name: SystemALabel,
+		}
+		if err := params.tbl.AddPartition(params.systemPartitionA, params.PartitionSize.System*Mi); err != nil {
+			return nil, fmt.Errorf("failed to allocate system partition A: %w", err)
+		}
+		params.systemPartitionB = &gpt.Partition{
+			Type: SystemBType,
+			Name: SystemBLabel,
+		}
+		if err := params.tbl.AddPartition(params.systemPartitionB, params.PartitionSize.System*Mi); err != nil {
+			return nil, fmt.Errorf("failed to allocate system partition B: %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")
+	}
+	// Create the data partition only if its size is specified.
+	if params.PartitionSize.Data != 0 {
+		params.dataPartition = &gpt.Partition{
+			Type: DataType,
+			Name: DataLabel,
+		}
+		if err := params.tbl.AddPartition(params.dataPartition, -1); err != nil {
+			return nil, fmt.Errorf("failed to allocate data partition: %w", err)
+		}
+	}
+
+	return params, nil
+}
+
+const Mi = 1024 * 1024
+
+// Create writes a Metropolis OS image to a block device.
+func Create(params *Params) (*efivarfs.LoadOption, error) {
+	p, err := Plan(params)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Apply()
+}