Skip to content

Add logic to install incus-osd to disk #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 3, 2025
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
11 changes: 11 additions & 0 deletions incus-osd/cmd/incus-osd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/lxc/incus-os/incus-osd/internal/applications"
"github.com/lxc/incus-os/incus-osd/internal/install"
"github.com/lxc/incus-os/incus-osd/internal/keyring"
"github.com/lxc/incus-os/incus-osd/internal/providers"
"github.com/lxc/incus-os/incus-osd/internal/seed"
Expand Down Expand Up @@ -46,6 +47,16 @@ func run() error {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
slog.SetDefault(logger)

// Check if we should try to install to a local disk.
if install.IsInstallNeeded() {
inst, err := install.NewInstall()
if err != nil {
return err
}

return inst.DoInstall(ctx)
}

// Create runtime path if missing.
err := os.Mkdir(runPath, 0o700)
if err != nil && !os.IsExist(err) {
Expand Down
3 changes: 3 additions & 0 deletions incus-osd/internal/install/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package install provides logic required to install incus-osd from
// a live media environment to a dedicated disk.
package install
325 changes: 325 additions & 0 deletions incus-osd/internal/install/install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
package install

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/lxc/incus/v6/shared/subprocess"
"golang.org/x/sys/unix"

"github.com/lxc/incus-os/incus-osd/internal/seed"
)

// Install holds information necessary to perform an installation.
type Install struct {
config *seed.InstallConfig
}

// IsInstallNeeded checks for the presence of an install.{json,yaml} file in the
// seed partition to indicate if we should attempt to install incus-osd to a local disk.
func IsInstallNeeded() bool {
_, err := seed.GetInstallConfig(seed.SeedPartitionPath)

// If we have any empty install file, that should still trigger an install.
if errors.Is(err, io.EOF) {
return true
}

return err == nil
}

// NewInstall returns a new Install object with its configuration, if any, populated from the seed partition.
func NewInstall() (*Install, error) {
ret := &Install{}

var err error
ret.config, err = seed.GetInstallConfig(seed.SeedPartitionPath)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}

return ret, nil
}

// DoInstall performs the necessary steps for installing incus-osd to a local disk.
func (i *Install) DoInstall(ctx context.Context) error {
slog.Info("Starting install of incus-osd to local disk.")

sourceDevice, err := i.getSourceDevice()
if err != nil {
return err
}

targetDevice, err := i.getTargetDevice(ctx, sourceDevice)
if err != nil {
return err
}

slog.Info("Installing incus-osd", "source", sourceDevice, "target", targetDevice)

err = i.performInstall(ctx, sourceDevice, targetDevice)
if err != nil {
return err
}

slog.Info("incus-osd was successfully installed. Please reboot the system and remove the external boot media.")

return i.rebootUponDeviceRemoval(ctx, sourceDevice)
}

// getSourceDevice determines the underlying device incus-osd is running on.
func (*Install) getSourceDevice() (string, error) {
// Start by determining the underlying device that /boot/EFI is on.
s := unix.Stat_t{}
err := unix.Stat("/boot/EFI", &s)
if err != nil {
return "", err
}

major := unix.Major(s.Dev)
minor := unix.Minor(s.Dev)
rootDev := fmt.Sprintf("%d:%d\n", major, minor)

// Get a list of all the block devices.
entries, err := os.ReadDir("/sys/class/block")
if err != nil {
return "", err
}

// Iterate through each of the block devices until we find the one for /boot/EFI.
for _, entry := range entries {
entryPath := filepath.Join("/sys/class/block", entry.Name())

dev, err := os.ReadFile(filepath.Join(entryPath, "dev")) //nolint:gosec
if err != nil {
continue
}

// We've found the device.
if string(dev) == rootDev {
// Read the symlink for the device, which will end with something like "/block/sda/sda1".
path, err := os.Readlink(entryPath)
if err != nil {
return "", err
}

// Drop the last element of the path (the partition), then get the base of the resulting path (the actual device).
parentDir, _ := filepath.Split(path)
underlyingDev := filepath.Base(parentDir)

return "/dev/" + underlyingDev, nil
}
}

return "", errors.New("unable to determine source device")
}

// getTargetDevice determines the underlying device to install incus-osd on.
func (i *Install) getTargetDevice(ctx context.Context, sourceDevice string) (string, error) {
type blockdevices struct {
KName string `json:"kname"`
ID string `json:"id-link"` //nolint:tagliatelle
}

type lsblkOutput struct {
Blockdevices []blockdevices `json:"blockdevices"`
}

potentialTargets := []blockdevices{}

// Get NVME drives first.
nvmeTargets := lsblkOutput{}
output, err := subprocess.RunCommandContext(ctx, "lsblk", "-N", "-iJnp", "-o", "KNAME,ID_LINK")
if err != nil {
return "", err
}

err = json.Unmarshal([]byte(output), &nvmeTargets)
if err != nil {
return "", err
}

potentialTargets = append(potentialTargets, nvmeTargets.Blockdevices...)

// Get SCSI drives second.
scsiTargets := lsblkOutput{}
output, err = subprocess.RunCommandContext(ctx, "lsblk", "-S", "-iJnp", "-o", "KNAME,ID_LINK")
if err != nil {
return "", err
}

err = json.Unmarshal([]byte(output), &scsiTargets)
if err != nil {
return "", err
}

potentialTargets = append(potentialTargets, scsiTargets.Blockdevices...)

// Get virtual drives last.
virtualTargets := lsblkOutput{}
output, err = subprocess.RunCommandContext(ctx, "lsblk", "-v", "-iJnp", "-o", "KNAME,ID_LINK")
if err != nil {
return "", err
}

err = json.Unmarshal([]byte(output), &virtualTargets)
if err != nil {
return "", err
}

potentialTargets = append(potentialTargets, virtualTargets.Blockdevices...)

// Ensure we found at least two devices (the install device and potential install device(s)). If no Target
// configuration was found, only proceed if exactly two devices were found.
if len(potentialTargets) < 2 {
return "", errors.New("no potential install devices found")
} else if i.config.Target == nil && len(potentialTargets) != 2 {
return "", errors.New("no target configuration provided, and didn't find exactly one install device")
}

// Loop through all disks, selecting the first one that isn't the source and matches the Target configuration.
for _, device := range potentialTargets {
if device.KName == sourceDevice {
continue
}

if i.config.Target == nil || strings.Contains(device.ID, i.config.Target.ID) {
return device.KName, nil
}
}

return "", errors.New("unable to determine target device")
}

// performInstall performs the steps to install incus-osd from the given target to the source device.
func (i *Install) performInstall(ctx context.Context, sourceDevice string, targetDevice string) error {
// Verify the target device doesn't already have a partition table, or that `ForceInstall` is set to true.
output, err := subprocess.RunCommandContext(ctx, "sgdisk", "-v", targetDevice)
if err != nil {
return err
}

if !strings.Contains(output, "Creating new GPT entries in memory") && !i.config.ForceInstall {
return fmt.Errorf("a partition table already exists on device '%s', and `ForceInstall` from install configuration isn't true", targetDevice)
}

// Turn off swap and unmount /boot.
_, err = subprocess.RunCommandContext(ctx, "swapoff", "-a")
if err != nil {
return err
}

err = unix.Unmount("/boot/", 0)
if err != nil {
return err
}

// Delete auto-created partitions from source device before cloning its GPT table.
for i := 9; i <= 11; i++ {
_, err = subprocess.RunCommandContext(ctx, "sgdisk", "-d", strconv.Itoa(i), sourceDevice)
if err != nil {
return err
}
}

// Clone the GPT partition table to the target device.
_, err = subprocess.RunCommandContext(ctx, "sgdisk", "-R", targetDevice, sourceDevice)
if err != nil {
return err
}

// Get partition prefixes, if needed.
sourcePartitionPrefix := getPartitionPrefix(sourceDevice)
targetPartitionPrefix := getPartitionPrefix(targetDevice)

doCopy := func(i int) error {
sourcePartition, err := os.OpenFile(fmt.Sprintf("%s%s%d", sourceDevice, sourcePartitionPrefix, i), os.O_RDONLY, 0o0600)
if err != nil {
return err
}
defer sourcePartition.Close()

targetPartition, err := os.OpenFile(fmt.Sprintf("%s%s%d", targetDevice, targetPartitionPrefix, i), os.O_WRONLY, 0o0600)
if err != nil {
return err
}
defer targetPartition.Close()

// Copy data in 1MiB chunks.
for {
_, err := io.CopyN(targetPartition, sourcePartition, 1024*1024)
if err != nil {
if errors.Is(err, io.EOF) {
break
}

return err
}
}

return nil
}

// Copy the partition contents.
for i := 1; i <= 8; i++ {
err := doCopy(i)
if err != nil {
return err
}
}

// Remove the install configuration file, if present, from the target seed partition.
targetSeedPartition := fmt.Sprintf("%s%s2", targetDevice, targetPartitionPrefix)
for _, filename := range []string{"install.json", "install.yaml"} {
_, err = subprocess.RunCommandContext(ctx, "tar", "-f", targetSeedPartition, "--delete", filename)
if err != nil && !strings.Contains(err.Error(), fmt.Sprintf("tar: %s: Not found in archive", filename)) {
return err
}
}

return nil
}

// rebootUponDeviceRemoval waits for the given device to disappear from /dev/, and once it does
// it will reboot the system. If ForceReoot is true in the config, the system will reboot immediately.
func (i *Install) rebootUponDeviceRemoval(_ context.Context, device string) error {
partition := fmt.Sprintf("%s%s1", device, getPartitionPrefix(device))

// Wait for the partition to disappear; if ForceReboot is true, skip the loop and immediately reboot.
for !i.config.ForceReboot {
_, err := os.Stat(partition)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
break
}

return err
}

time.Sleep(1 * time.Second)
}

// Do a final sync and then reboot the system.
unix.Sync()

return os.WriteFile("/proc/sysrq-trigger", []byte("b"), 0o600)
}

// getPartitionPrefix returns the necessary partition prefix, if any, for a give device.
// nvme devices have partitions named "pN", while traditional disk partitions are just "N".
func getPartitionPrefix(device string) string {
if strings.Contains(device, "/nvme") {
return "p"
}

return ""
}
26 changes: 26 additions & 0 deletions incus-osd/internal/seed/install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package seed

// InstallConfig defines a struct to hold install configuration.
type InstallConfig struct {
ForceInstall bool `json:"force_install" yaml:"forceInstall"` // If true, ignore any existing data on target install disk.
ForceReboot bool `json:"force_reboot" yaml:"forceReboot"` // If true, reboot the system automatically upon completion rather than waiting for the install media to be removed.
Target *InstallConfigTarget `json:"target" yaml:"target"` // Optional selector for the target install disk; if not set, expect a single drive to be present.
}

// InstallConfigTarget defines options used to select the target install disk.
type InstallConfigTarget struct {
ID string `json:"id" yaml:"id"` // Name as listed in /dev/disk/by-id/, glob supported.
}

// GetInstallConfig extracts the list of applications from the seed data.
func GetInstallConfig(partition string) (*InstallConfig, error) {
// Get the install configuration.
var config InstallConfig

err := parseFileContents(partition, "install", &config)
if err != nil {
return &InstallConfig{}, err
}

return &config, nil
}
2 changes: 1 addition & 1 deletion incus-osd/internal/seed/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
)

// GetNetwork extracts the list of applications from the seed data.
// GetNetwork extracts the network configuration from the seed data.
func GetNetwork(_ context.Context, partition string) (*NetworkConfig, error) {
// Get the network configuration.
var config NetworkConfig
Expand Down
1 change: 1 addition & 0 deletions mkosi.images/base/mkosi.conf.d/03-core-packages.conf
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Packages=
dbus
e2fsprogs
gdisk
lldpd
lvm2
lvm2-lockd
Expand Down
Loading