diff --git a/internal/agent/hooks/hook.go b/internal/agent/hooks/hook.go index 3a5b5bf..ef6b70e 100644 --- a/internal/agent/hooks/hook.go +++ b/internal/agent/hooks/hook.go @@ -30,6 +30,15 @@ var FirstBoot = []Interface{ &GrubPostInstallOptions{}, } +// AfterUkiInstall sets which Hooks to run after uki runs the install action +var AfterUkiInstall = []Interface{} + +// AfterUkiReset sets which Hooks to run after uki runs the install action +var AfterUkiReset = []Interface{} + +// AfterUkiUpgrade sets which Hooks to run after uki runs the install action +var AfterUkiUpgrade = []Interface{} + func Run(c config.Config, spec v1.Spec, hooks ...Interface) error { for _, h := range hooks { if err := h.Run(c, spec); err != nil { diff --git a/main.go b/main.go index 56e809e..0163658 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ import ( "github.com/kairos-io/kairos-agent/v2/internal/webui" agentConfig "github.com/kairos-io/kairos-agent/v2/pkg/config" v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + "github.com/kairos-io/kairos-agent/v2/pkg/uki" "github.com/kairos-io/kairos-sdk/bundles" "github.com/kairos-io/kairos-sdk/collector" "github.com/kairos-io/kairos-sdk/machine" @@ -651,6 +652,112 @@ The validate command expects a configuration file as its only argument. Local fi return nil }, }, + { + Name: "uki", + Usage: "UKI subcommands", + Description: "UKI subcommands", + // we could set the flag --source at this level so we could have the flag for all subcommands but that translates into an ugly command + // in which you need to put the source flag before the subcommand, which is a mess. Just bad UX. + // command level: kairos-agent uki --source oci:whatever install + // subcommand level: kairos-agent uki install --source oci:whatever + Subcommands: []*cli.Command{ + { + Name: "install", + Usage: "Install to disk", + UsageText: "install [--device DEVICE]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Usage: "Source for install. Composed of `type:address`. Accepts `file:`,`dir:` or `oci:` for the type of source.\nFor example `file:/var/share/myimage.tar`, `dir:/tmp/extracted` or `oci:repo/image:tag`", + Action: func(c *cli.Context, s string) error { + return validateSource(s) + }, + }, + &cli.StringFlag{ + Name: "device", + }, + }, + Action: func(c *cli.Context) error { + config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs) + if err != nil { + return err + } + // Load the spec from the config + installSpec, err := agentConfig.ReadUkiInstallSpecFromConfig(config) + if err != nil { + return err + } + + if c.String("device") != "" { + installSpec.Target = c.String("device") + } + + installAction := uki.NewInstallAction(config, installSpec) + return installAction.Run() + }, + }, + { + Name: "upgrade", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Usage: "Source for upgrade. Composed of `type:address`. Accepts `file:`,`dir:` or `oci:` for the type of source.\nFor example `file:/var/share/myimage.tar`, `dir:/tmp/extracted` or `oci:repo/image:tag`", + Action: func(c *cli.Context, s string) error { + return validateSource(s) + }, + }, + }, + Before: func(c *cli.Context) error { + return fmt.Errorf("not implemented") + }, + Action: func(c *cli.Context) error { + config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs, collector.StrictValidation(c.Bool("strict-validation"))) + if err != nil { + return err + } + + // Load the spec from the config + upgradeSpec, err := agentConfig.ReadUkiUpgradeFromConfig(config) + if err != nil { + return err + } + + upgradeAction := uki.NewUpgradeAction(config, upgradeSpec) + return upgradeAction.Run() + }, + }, + { + Name: "reset", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Usage: "Source for upgrade. Composed of `type:address`. Accepts `file:`,`dir:` or `oci:` for the type of source.\nFor example `file:/var/share/myimage.tar`, `dir:/tmp/extracted` or `oci:repo/image:tag`", + Action: func(c *cli.Context, s string) error { + return validateSource(s) + }, + }, + }, + Before: func(c *cli.Context) error { + return fmt.Errorf("not implemented") + }, + Action: func(c *cli.Context) error { + config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs, collector.StrictValidation(c.Bool("strict-validation"))) + if err != nil { + return err + } + + // Load the spec from the config + resetSpec, err := agentConfig.ReadUkiResetSpecFromConfig(config) + if err != nil { + return err + } + + resetAction := uki.NewResetAction(config, resetSpec) + return resetAction.Run() + }, + }, + }, + }, } func main() { diff --git a/pkg/config/spec.go b/pkg/config/spec.go index 629faa1..d695978 100644 --- a/pkg/config/spec.go +++ b/pkg/config/spec.go @@ -478,6 +478,73 @@ func ReadInstallSpecFromConfig(c *Config) (*v1.InstallSpec, error) { return installSpec, nil } +// ReadUkiResetSpecFromConfig will return a proper v1.ResetUkiSpec based on an agent Config +func ReadUkiResetSpecFromConfig(c *Config) (*v1.ResetUkiSpec, error) { + sp, err := ReadSpecFromCloudConfig(c, "reset-uki") + if err != nil { + return &v1.ResetUkiSpec{}, err + } + resetSpec := sp.(*v1.ResetUkiSpec) + return resetSpec, nil +} + +func NewUkiInstallSpec(cfg *Config) (*v1.InstallUkiSpec, error) { + spec := &v1.InstallUkiSpec{ + Target: cfg.Install.Device, + } + + // Calculate the partitions afterwards so they use the image sizes for the final partition sizes + spec.Partitions.EFI = &v1.Partition{ + FilesystemLabel: constants.EfiLabel, + Size: constants.ImgSize, // TODO: Fix this and set proper size based on the source size + Name: constants.EfiPartName, + FS: constants.EfiFs, + MountPoint: constants.EfiDir, + Flags: []string{"esp"}, + } + spec.Partitions.OEM = &v1.Partition{ + FilesystemLabel: constants.OEMLabel, + Size: constants.OEMSize, + Name: constants.OEMPartName, + FS: constants.LinuxFs, + MountPoint: constants.OEMDir, + Flags: []string{}, + } + spec.Partitions.Persistent = &v1.Partition{ + FilesystemLabel: constants.PersistentLabel, + Size: constants.PersistentSize, + Name: constants.PersistentPartName, + FS: constants.LinuxFs, + MountPoint: constants.PersistentDir, + Flags: []string{}, + } + + // TODO: Which key to use? install or install-uki? + err := unmarshallFullSpec(cfg, "install", spec) + + return spec, err +} + +// ReadUkiInstallSpecFromConfig will return a proper v1.InstallUkiSpec based on an agent Config +func ReadUkiInstallSpecFromConfig(c *Config) (*v1.InstallUkiSpec, error) { + sp, err := ReadSpecFromCloudConfig(c, "install-uki") + if err != nil { + return &v1.InstallUkiSpec{}, err + } + installSpec := sp.(*v1.InstallUkiSpec) + return installSpec, nil +} + +// ReadUkiUpgradeFromConfig will return a proper v1.UpgradeUkiSpec based on an agent Config +func ReadUkiUpgradeFromConfig(c *Config) (*v1.UpgradeUkiSpec, error) { + sp, err := ReadSpecFromCloudConfig(c, "upgrade-uki") + if err != nil { + return &v1.UpgradeUkiSpec{}, err + } + upgradeSpec := sp.(*v1.UpgradeUkiSpec) + return upgradeSpec, nil +} + // GetSourceSize will try to gather the actual size of the source // Useful to create the exact size of images and by side effect the partition size // This helps adjust the size to be juuuuust right. @@ -571,6 +638,14 @@ func ReadSpecFromCloudConfig(r *Config, spec string) (v1.Spec, error) { sp, err = NewUpgradeSpec(r) case "reset": sp, err = NewResetSpec(r) + case "install-uki": + sp, err = NewUkiInstallSpec(r) + case "reset-uki": + // TODO: Fill with proper defaults + sp = &v1.ResetUkiSpec{} + case "upgrade-uki": + // TODO: Fill with proper defaults + sp = &v1.UpgradeUkiSpec{} default: return nil, fmt.Errorf("spec not valid: %s", spec) } diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 058c9c6..b16bd51 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -106,6 +106,9 @@ const ( SignedShim = "shim.efi" Rsync = "rsync" + + UkiSource = "/run/install/uki" + UkiCdromSource = "/run/install/cdrom" ) func GetCloudInitPaths() []string { diff --git a/pkg/elemental/elemental.go b/pkg/elemental/elemental.go index 3358dc2..5792b0c 100644 --- a/pkg/elemental/elemental.go +++ b/pkg/elemental/elemental.go @@ -50,27 +50,27 @@ func (e *Elemental) FormatPartition(part *v1.Partition, opts ...string) error { // PartitionAndFormatDevice creates a new empty partition table on target disk // and applies the configured disk layout by creating and formatting all // required partitions -func (e *Elemental) PartitionAndFormatDevice(i *v1.InstallSpec) error { +func (e *Elemental) PartitionAndFormatDevice(i v1.SharedInstallSpec) error { disk := partitioner.NewDisk( - i.Target, + i.GetTarget(), partitioner.WithRunner(e.config.Runner), partitioner.WithFS(e.config.Fs), partitioner.WithLogger(e.config.Logger), ) if !disk.Exists() { - e.config.Logger.Errorf("Disk %s does not exist", i.Target) - return fmt.Errorf("disk %s does not exist", i.Target) + e.config.Logger.Errorf("Disk %s does not exist", i.GetTarget()) + return fmt.Errorf("disk %s does not exist", i.GetTarget()) } e.config.Logger.Infof("Partitioning device...") - out, err := disk.NewPartitionTable(i.PartTable) + out, err := disk.NewPartitionTable(i.GetPartTable()) if err != nil { e.config.Logger.Errorf("Failed creating new partition table: %s", out) return err } - parts := i.Partitions.PartitionsByInstallOrder(i.ExtraPartitions) + parts := i.GetPartitions().PartitionsByInstallOrder(i.GetExtraPartitions()) return e.createPartitions(disk, parts) } diff --git a/pkg/types/v1/config.go b/pkg/types/v1/config.go index f524470..a474403 100644 --- a/pkg/types/v1/config.go +++ b/pkg/types/v1/config.go @@ -41,6 +41,14 @@ type Spec interface { ShouldShutdown() bool } +// SharedInstallSpec is the interface that Install specs need to implement +type SharedInstallSpec interface { + GetPartTable() string + GetTarget() string + GetPartitions() ElementalPartitions + GetExtraPartitions() PartitionList +} + // InstallSpec struct represents all the installation action details type InstallSpec struct { Target string `yaml:"device,omitempty" mapstructure:"device"` @@ -106,8 +114,12 @@ func (i *InstallSpec) Sanitize() error { return i.Partitions.SetFirmwarePartitions(i.Firmware, i.PartTable) } -func (i *InstallSpec) ShouldReboot() bool { return i.Reboot } -func (i *InstallSpec) ShouldShutdown() bool { return i.PowerOff } +func (i *InstallSpec) ShouldReboot() bool { return i.Reboot } +func (i *InstallSpec) ShouldShutdown() bool { return i.PowerOff } +func (i *InstallSpec) GetTarget() string { return i.Target } +func (i *InstallSpec) GetPartTable() string { return i.PartTable } +func (i *InstallSpec) GetPartitions() ElementalPartitions { return i.Partitions } +func (i *InstallSpec) GetExtraPartitions() PartitionList { return i.ExtraPartitions } // ResetSpec struct represents all the reset action details type ResetSpec struct { @@ -476,3 +488,50 @@ type DockerImageMeta struct { Digest string `yaml:"digest,omitempty"` Size int64 `yaml:"size,omitempty"` } + +type InstallUkiSpec struct { + Target string `yaml:"device,omitempty" mapstructure:"device"` + Reboot bool `yaml:"reboot,omitempty" mapstructure:"reboot"` + PowerOff bool `yaml:"poweroff,omitempty" mapstructure:"poweroff"` + Partitions ElementalPartitions `yaml:"partitions,omitempty" mapstructure:"partitions"` + ExtraPartitions PartitionList `yaml:"extra-partitions,omitempty" mapstructure:"extra-partitions"` + CloudInit []string `yaml:"cloud-init,omitempty" mapstructure:"cloud-init"` +} + +func (i *InstallUkiSpec) Sanitize() error { + var err error + return err +} + +func (i *InstallUkiSpec) ShouldReboot() bool { return i.Reboot } +func (i *InstallUkiSpec) ShouldShutdown() bool { return i.PowerOff } +func (i *InstallUkiSpec) GetTarget() string { return i.Target } +func (i *InstallUkiSpec) GetPartTable() string { return "gpt" } +func (i *InstallUkiSpec) GetPartitions() ElementalPartitions { return i.Partitions } +func (i *InstallUkiSpec) GetExtraPartitions() PartitionList { return i.ExtraPartitions } + +type UpgradeUkiSpec struct { + Reboot bool `yaml:"reboot,omitempty" mapstructure:"reboot"` + PowerOff bool `yaml:"poweroff,omitempty" mapstructure:"poweroff"` +} + +func (i *UpgradeUkiSpec) Sanitize() error { + var err error + return err +} + +func (i *UpgradeUkiSpec) ShouldReboot() bool { return i.Reboot } +func (i *UpgradeUkiSpec) ShouldShutdown() bool { return i.PowerOff } + +type ResetUkiSpec struct { + Reboot bool `yaml:"reboot,omitempty" mapstructure:"reboot"` + PowerOff bool `yaml:"poweroff,omitempty" mapstructure:"poweroff"` +} + +func (i *ResetUkiSpec) Sanitize() error { + var err error + return err +} + +func (i *ResetUkiSpec) ShouldReboot() bool { return i.Reboot } +func (i *ResetUkiSpec) ShouldShutdown() bool { return i.PowerOff } diff --git a/pkg/uki/install.go b/pkg/uki/install.go new file mode 100644 index 0000000..b28c97e --- /dev/null +++ b/pkg/uki/install.go @@ -0,0 +1,147 @@ +package uki + +import ( + hook "github.com/kairos-io/kairos-agent/v2/internal/agent/hooks" + "github.com/kairos-io/kairos-agent/v2/pkg/config" + "github.com/kairos-io/kairos-agent/v2/pkg/constants" + "github.com/kairos-io/kairos-agent/v2/pkg/elemental" + v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + "github.com/kairos-io/kairos-agent/v2/pkg/utils" + fsutils "github.com/kairos-io/kairos-agent/v2/pkg/utils/fs" + events "github.com/kairos-io/kairos-sdk/bus" + "os" + "path/filepath" +) + +type InstallAction struct { + cfg *config.Config + spec *v1.InstallUkiSpec +} + +func NewInstallAction(cfg *config.Config, spec *v1.InstallUkiSpec) *InstallAction { + return &InstallAction{cfg: cfg, spec: spec} +} + +func (i *InstallAction) Run() (err error) { + e := elemental.NewElemental(i.cfg) + cleanup := utils.NewCleanStack() + defer func() { err = cleanup.Cleanup(err) }() + // Run pre-install stage + _ = utils.RunStage(i.cfg, "kairos-uki-install.pre") + _ = events.RunHookScript("/usr/bin/kairos-agent.uki.install.pre.hook") + + // Get source (from spec?) + // If source is empty then we need to find the media we booted from....to get the efi files... + // cdrom is kind fo easy... + // we set the label EFI_ISO_BOOT so we look for that and then mount the image inside... + // TODO: Extract this to a different functions or something. Maybe PrepareUKISource or something + _ = fsutils.MkdirAll(i.cfg.Fs, constants.UkiCdromSource, os.ModeDir|os.ModePerm) + _ = fsutils.MkdirAll(i.cfg.Fs, constants.UkiSource, os.ModeDir|os.ModePerm) + + cdRom := &v1.Partition{ + FilesystemLabel: "UKI_ISO_INSTALL", // TODO: Hardcoded on ISO creation + FS: "iso9660", + Path: "/dev/disk/by-label/UKI_ISO_INSTALL", + MountPoint: constants.UkiCdromSource, + } + err = e.MountPartition(cdRom) + + if err != nil { + return err + } + cleanup.Push(func() error { + return e.UnmountPartition(cdRom) + }) + + // TODO: hardcoded + image := &v1.Image{ + File: "/run/install/cdrom/efiboot.img", // TODO: Hardcoded on ISO creation + Label: "UKI_SOURCE", // Made up, only for logging + MountPoint: constants.UkiSource, + } + + err = e.MountImage(image) + if err != nil { + return err + } + cleanup.Push(func() error { + return e.UnmountImage(image) + }) + + // Create EFI partition (fat32), we already create the efi partition on normal efi install,we can reuse that? + // Create COS_OEM/COS_PERSISTANT if set (optional) + // I guess we need to set sensible default values here for sizes? oem -> 64Mb as usual but if no persistent then EFI max size? + // if persistent then EFI = source size * 2 (or maybe 3 times! so we can upgrade!) and then persistent the rest of the disk? + + // Deactivate any active volume on target + err = e.DeactivateDevices() + if err != nil { + return err + } + // Partition device + err = e.PartitionAndFormatDevice(i.spec) + if err != nil { + return err + } + + err = e.MountPartitions(i.spec.GetPartitions().PartitionsByMountPoint(false)) + if err != nil { + return err + } + cleanup.Push(func() error { + return e.UnmountPartitions(i.spec.GetPartitions().PartitionsByMountPoint(true)) + }) + + // Before install hook happens after partitioning but before the image OS is applied (this is for compatibility with normal install, so users can reuse their configs) + err = Hook(i.cfg, constants.BeforeInstallHook) + if err != nil { + return err + } + + // Store cloud-config in TPM or copy it to COS_OEM? + // Copy cloud-init if any + err = e.CopyCloudConfig(i.spec.CloudInit) + if err != nil { + return err + } + // Create dir structure + // - /EFI/Kairos/ -> Store our older efi images ? + // - /EFI/BOOT/ -> Default fallback dir (efi search for bootaa64.efi or bootx64.efi if no entries in the boot manager) + + err = fsutils.MkdirAll(i.cfg.Fs, filepath.Join(constants.EfiDir, "EFI", "BOOT"), constants.DirPerm) + if err != nil { + return err + } + + // Copy the efi file into the proper dir + source := v1.NewDirSrc(constants.UkiSource) + _, err = e.DumpSource(i.spec.Partitions.EFI.MountPoint, source) + if err != nil { + return err + } + + // after install hook happens after install (this is for compatibility with normal install, so users can reuse their configs) + err = Hook(i.cfg, constants.AfterInstallHook) + if err != nil { + return err + } + // Remove all boot manager entries? + // Create boot manager entry + // Set default entry to the one we just created + // Probably copy efi utils, like the Mokmanager and even the shim or grub efi to help with troubleshooting? + _ = utils.RunStage(i.cfg, "kairos-uki-install.after") + _ = events.RunHookScript("/usr/bin/kairos-agent.uki.install.after.hook") //nolint:errcheck + + return hook.Run(*i.cfg, i.spec, hook.AfterUkiInstall...) +} + +// Hook is RunStage wrapper that only adds logic to ignore errors +// in case v1.Config.Strict is set to false +func Hook(config *config.Config, hook string) error { + config.Logger.Infof("Running %s hook", hook) + err := utils.RunStage(config, hook) + if !config.Strict { + err = nil + } + return err +} diff --git a/pkg/uki/reset.go b/pkg/uki/reset.go new file mode 100644 index 0000000..e405a6e --- /dev/null +++ b/pkg/uki/reset.go @@ -0,0 +1,35 @@ +package uki + +import ( + hook "github.com/kairos-io/kairos-agent/v2/internal/agent/hooks" + "github.com/kairos-io/kairos-agent/v2/pkg/config" + v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + elementalUtils "github.com/kairos-io/kairos-agent/v2/pkg/utils" + events "github.com/kairos-io/kairos-sdk/bus" +) + +type ResetAction struct { + cfg *config.Config + spec *v1.ResetUkiSpec +} + +func NewResetAction(cfg *config.Config, spec *v1.ResetUkiSpec) *ResetAction { + return &ResetAction{cfg: cfg, spec: spec} +} + +func (i *ResetAction) Run() (err error) { + // Run pre-install stage + _ = elementalUtils.RunStage(i.cfg, "kairos-uki-reset.pre") + _ = events.RunHookScript("/usr/bin/kairos-agent.uki.reset.pre.hook") + + // Get source (from spec?) + // Copy the efi file into the proper dir + // Remove all boot manager entries? + // Create boot manager entry + // Set default entry to the one we just created + + _ = elementalUtils.RunStage(i.cfg, "kairos-uki-reset.after") + _ = events.RunHookScript("/usr/bin/kairos-agent.uki.reset.after.hook") //nolint:errcheck + + return hook.Run(*i.cfg, i.spec, hook.AfterUkiReset...) +} diff --git a/pkg/uki/upgrade.go b/pkg/uki/upgrade.go new file mode 100644 index 0000000..59cc123 --- /dev/null +++ b/pkg/uki/upgrade.go @@ -0,0 +1,35 @@ +package uki + +import ( + hook "github.com/kairos-io/kairos-agent/v2/internal/agent/hooks" + "github.com/kairos-io/kairos-agent/v2/pkg/config" + v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + elementalUtils "github.com/kairos-io/kairos-agent/v2/pkg/utils" + events "github.com/kairos-io/kairos-sdk/bus" +) + +type UpgradeAction struct { + cfg *config.Config + spec *v1.UpgradeUkiSpec +} + +func NewUpgradeAction(cfg *config.Config, spec *v1.UpgradeUkiSpec) *UpgradeAction { + return &UpgradeAction{cfg: cfg, spec: spec} +} + +func (i *UpgradeAction) Run() (err error) { + // Run pre-install stage + _ = elementalUtils.RunStage(i.cfg, "kairos-uki-upgrade.pre") + _ = events.RunHookScript("/usr/bin/kairos-agent.uki.upgrade.pre.hook") + + // Get source (from spec?) + // Copy the efi file into the proper dir + // Remove all boot manager entries? + // Create boot manager entry + // Set default entry to the one we just created + + _ = elementalUtils.RunStage(i.cfg, "kairos-uki-upgrade.after") + _ = events.RunHookScript("/usr/bin/kairos-agent.uki.upgrade.after.hook") //nolint:errcheck + + return hook.Run(*i.cfg, i.spec, hook.AfterUkiUpgrade...) +}