2023-05-05 16:43:21 +00:00
/ *
Copyright © 2022 SUSE LLC
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 .
* /
package utils
import (
2024-09-10 10:23:15 +00:00
"bytes"
2023-05-05 16:43:21 +00:00
"fmt"
2023-07-25 13:21:34 +00:00
agentConfig "github.com/kairos-io/kairos-agent/v2/pkg/config"
2024-09-10 10:23:15 +00:00
v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1"
2023-07-25 13:21:34 +00:00
"github.com/kairos-io/kairos-agent/v2/pkg/utils/fs"
2024-01-11 10:24:43 +00:00
"github.com/kairos-io/kairos-sdk/utils"
2023-05-05 16:43:21 +00:00
"io/fs"
2025-04-25 08:43:21 +00:00
"os"
2023-05-05 16:43:21 +00:00
"path/filepath"
2024-09-10 10:23:15 +00:00
"sort"
2023-05-05 16:43:21 +00:00
"strings"
2023-07-10 12:39:48 +00:00
"github.com/kairos-io/kairos-agent/v2/pkg/constants"
cnst "github.com/kairos-io/kairos-agent/v2/pkg/constants"
2023-05-05 16:43:21 +00:00
)
// Grub is the struct that will allow us to install grub to the target device
type Grub struct {
2023-07-25 13:21:34 +00:00
config * agentConfig . Config
2023-05-05 16:43:21 +00:00
}
2023-07-25 13:21:34 +00:00
func NewGrub ( config * agentConfig . Config ) * Grub {
2023-05-05 16:43:21 +00:00
g := & Grub {
config : config ,
}
return g
}
// Install installs grub into the device, copy the config file and add any extra TTY to grub
2024-12-17 14:05:38 +00:00
// TODO: Make it more generic to be able to call it from other places
// i.e.: filepath.Join(cnst.ActiveDir, "etc/kairos-release") seraches for the file in the active dir, we should be looking into the rootdir?
// filepath.Join(cnst.EfiDir, "EFI/boot/grub.cfg") we also write into the efi dir directly, this should be the a var maybe?
2023-05-05 16:43:21 +00:00
func ( g Grub ) Install ( target , rootDir , bootDir , grubConf , tty string , efi bool , stateLabel string ) ( err error ) { // nolint:gocyclo
var grubargs [ ] string
var grubdir , finalContent string
// At this point the active mountpoint has all the data from the installation source, so we should be able to use
// the grub.cfg bundled in there
systemgrub := "grub2"
// only install grub on non-efi systems
if ! efi {
g . config . Logger . Info ( "Installing GRUB.." )
2025-04-25 08:43:21 +00:00
// Find where in the rootDir the grub2 files for i386-pc are
// Use the modinfo.sh as a marker
grubdir = findGrubDir ( g . config . Fs , rootDir )
if grubdir == "" {
g . config . Logger . Logger . Error ( ) . Str ( "path" , rootDir ) . Msg ( "Failed to find grub dir" )
return fmt . Errorf ( "failed to find grub dir under %s" , rootDir )
}
2023-05-05 16:43:21 +00:00
grubargs = append (
grubargs ,
2025-04-25 08:43:21 +00:00
fmt . Sprintf ( "--directory=%s" , grubdir ) ,
2023-05-05 16:43:21 +00:00
fmt . Sprintf ( "--boot-directory=%s" , bootDir ) ,
"--target=i386-pc" ,
target ,
)
g . config . Logger . Debugf ( "Running grub with the following args: %s" , grubargs )
2025-04-25 08:43:21 +00:00
grubBin := FindCommand ( g . config . Fs , "" , [ ] string {
filepath . Join ( rootDir , "/usr/sbin/" , "grub2-install" ) ,
filepath . Join ( rootDir , "/usr/bin/" , "grub2-install" ) ,
filepath . Join ( rootDir , "/sbin/" , "grub2-install" ) ,
filepath . Join ( rootDir , "/usr/sbin/" , "grub-install" ) ,
filepath . Join ( rootDir , "/usr/bin/" , "grub-install" ) ,
filepath . Join ( rootDir , "/sbin/" , "grub-install" ) ,
} )
g . config . Logger . Logger . Debug ( ) . Str ( "command" , grubBin ) . Msg ( "Found grub binary" )
if grubBin == "" {
g . config . Logger . Logger . Error ( ) . Str ( "path" , rootDir ) . Msg ( "Grub binary not found in path" )
return fmt . Errorf ( "grub binary not found in path" )
}
out , err := g . config . Runner . Run ( grubBin , grubargs ... )
2023-05-05 16:43:21 +00:00
if err != nil {
g . config . Logger . Errorf ( string ( out ) )
return err
}
g . config . Logger . Infof ( "Grub install to device %s complete" , target )
// Select the proper dir for grub - this assumes that we previously run a grub install command, which is not the case in EFI
// In the EFI case we default to grub2
2023-07-25 13:21:34 +00:00
if ok , _ := fsutils . IsDir ( g . config . Fs , filepath . Join ( bootDir , "grub" ) ) ; ok {
2023-05-05 16:43:21 +00:00
systemgrub = "grub"
}
}
grubdir = filepath . Join ( rootDir , grubConf )
g . config . Logger . Infof ( "Using grub config dir %s" , grubdir )
grubCfg , err := g . config . Fs . ReadFile ( grubdir )
if err != nil {
g . config . Logger . Errorf ( "Failed reading grub config file: %s" , filepath . Join ( rootDir , grubConf ) )
return err
}
// Create Needed dir under state partition to store the grub.cfg and any needed modules
2023-07-25 13:21:34 +00:00
err = fsutils . MkdirAll ( g . config . Fs , filepath . Join ( bootDir , fmt . Sprintf ( "%s/%s-efi" , systemgrub , g . config . Arch ) ) , cnst . DirPerm )
2023-05-05 16:43:21 +00:00
if err != nil {
return fmt . Errorf ( "error creating grub dir: %s" , err )
}
grubConfTarget , err := g . config . Fs . Create ( filepath . Join ( bootDir , fmt . Sprintf ( "%s/grub.cfg" , systemgrub ) ) )
if err != nil {
return err
}
defer grubConfTarget . Close ( )
if tty == "" {
// Get current tty and remove /dev/ from its name
2024-09-10 10:23:15 +00:00
out , err := g . config . Fs . Readlink ( "/dev/fd/0" )
tty = strings . TrimPrefix ( strings . TrimSpace ( out ) , "/dev/" )
2023-05-05 16:43:21 +00:00
if err != nil {
g . config . Logger . Warnf ( "failed to find current tty, leaving it unset" )
tty = ""
}
}
2023-07-25 13:21:34 +00:00
ttyExists , _ := fsutils . Exists ( g . config . Fs , fmt . Sprintf ( "/dev/%s" , tty ) )
2023-05-05 16:43:21 +00:00
if ttyExists && tty != "" && tty != "console" && tty != constants . DefaultTty {
// We need to add a tty to the grub file
g . config . Logger . Infof ( "Adding extra tty (%s) to grub.cfg" , tty )
defConsole := fmt . Sprintf ( "console=%s" , constants . DefaultTty )
finalContent = strings . Replace ( string ( grubCfg ) , defConsole , fmt . Sprintf ( "%s console=%s" , defConsole , tty ) , - 1 )
} else {
// We don't add anything, just read the file
finalContent = string ( grubCfg )
}
g . config . Logger . Infof ( "Copying grub contents from %s to %s" , grubdir , filepath . Join ( bootDir , fmt . Sprintf ( "%s/grub.cfg" , systemgrub ) ) )
_ , err = grubConfTarget . WriteString ( finalContent )
if err != nil {
return err
}
if efi {
// Copy required extra modules to boot dir under the state partition
// otherwise if we insmod it will fail to find them
// We no longer call grub-install here so the modules are not setup automatically in the state partition
// as they were before. We now use the bundled grub.efi provided by the shim package
g . config . Logger . Infof ( "Generating grub files for efi on %s" , target )
var foundModules bool
2023-09-13 09:07:28 +00:00
for _ , m := range constants . GetGrubModules ( ) {
2023-07-25 13:21:34 +00:00
err = fsutils . WalkDirFs ( g . config . Fs , rootDir , func ( path string , d fs . DirEntry , err error ) error {
2023-05-05 16:43:21 +00:00
if err != nil {
return err
}
if d . Name ( ) == m && strings . Contains ( path , g . config . Arch ) {
fileWriteName := filepath . Join ( bootDir , fmt . Sprintf ( "%s/%s-efi/%s" , systemgrub , g . config . Arch , m ) )
g . config . Logger . Debugf ( "Copying %s to %s" , path , fileWriteName )
fileContent , err := g . config . Fs . ReadFile ( path )
if err != nil {
return fmt . Errorf ( "error reading %s: %s" , path , err )
}
err = g . config . Fs . WriteFile ( fileWriteName , fileContent , cnst . FilePerm )
if err != nil {
return fmt . Errorf ( "error writing %s: %s" , fileWriteName , err )
}
foundModules = true
return nil
}
return err
} )
if ! foundModules {
return fmt . Errorf ( "did not find grub modules under %s" , rootDir )
}
}
2023-09-13 09:07:28 +00:00
copyGrubFonts ( g . config , rootDir , grubdir , systemgrub )
2023-07-25 13:21:34 +00:00
err = fsutils . MkdirAll ( g . config . Fs , filepath . Join ( cnst . EfiDir , "EFI/boot/" ) , cnst . DirPerm )
2023-05-05 16:43:21 +00:00
if err != nil {
g . config . Logger . Errorf ( "Error creating dirs: %s" , err )
return err
}
2024-10-07 09:44:05 +00:00
flavor , err := utils . OSRelease ( "FLAVOR" , filepath . Join ( cnst . ActiveDir , "etc/kairos-release" ) )
2024-07-15 07:32:00 +00:00
if err != nil {
2024-10-07 09:44:05 +00:00
// Fallback to os-release
flavor , err = utils . OSRelease ( "FLAVOR" , filepath . Join ( cnst . ActiveDir , "os/kairos-release" ) )
if err != nil {
g . config . Logger . Warnf ( "Failed reading release info from %s and %s: %v" , filepath . Join ( cnst . ActiveDir , "etc/kairos-release" ) , filepath . Join ( cnst . ActiveDir , "os/kairos-release" ) , err )
}
2024-07-15 07:32:00 +00:00
}
2024-10-01 14:15:53 +00:00
if flavor == "" {
// If os-release is gone with our vars, we dont know what flavor are we in, we should know if we are on ubuntu as we need
// a workaround for the grub efi install
// So lets try to get the info from the normal keys shipped with the os
flavorFromId , err := utils . OSRelease ( "ID" , filepath . Join ( cnst . ActiveDir , "etc/os-release" ) )
if err != nil {
g . config . Logger . Logger . Err ( err ) . Msg ( "Getting flavor" )
}
if strings . Contains ( strings . ToLower ( flavorFromId ) , "ubuntu" ) {
flavor = "ubuntu"
}
}
2024-07-15 07:32:00 +00:00
g . config . Logger . Debugf ( "Detected Flavor: %s" , flavor )
2023-05-05 16:43:21 +00:00
// Copy needed files for efi boot
2024-01-11 10:24:43 +00:00
// This seems like a chore while we could provide a package for those bundled files as they are just a shim and a grub efi
// BUT this is needed for secureboot
// The shim contains the signature from microsoft and the shim provider (i.e. upstream distro like fedora, suse, etc)
// So if we use the shim+grub from a generic package (i.e. ubuntu) it WILL boot with secureboot
// but when loading the kernel it will fail because the kernel is not signed by the shim provider or grub provider
// the kernel signature would be from fedora while the shim signature would be from ubuntu
// This is why if we want to support secureboot we need to copy the shim+grub from the rootfs default paths instead of
// providing a generic package
2024-07-15 07:32:00 +00:00
// Shim is not available in Alpine + rpi
2024-10-07 09:44:05 +00:00
var model string
model , err = utils . OSRelease ( "KAIROS_MODEL" , filepath . Join ( cnst . ActiveDir , "etc/kairos-release" ) )
if err != nil {
// Fallback into os-release
model , err = utils . OSRelease ( "KAIROS_MODEL" , filepath . Join ( cnst . ActiveDir , "etc/os-release" ) )
if err != nil {
g . config . Logger . Warnf ( "Failed reading model info from %s and %s: %v" , filepath . Join ( cnst . ActiveDir , "etc/kairos-release" ) , filepath . Join ( cnst . ActiveDir , "os/kairos-release" ) , err )
}
}
2024-07-15 07:32:00 +00:00
if strings . Contains ( strings . ToLower ( flavor ) , "alpine" ) && strings . Contains ( strings . ToLower ( model ) , "rpi" ) {
g . config . Logger . Debug ( "Running on Alpine+RPI, not copying shim or grub." )
} else {
err = g . copyShim ( )
2024-01-15 14:15:05 +00:00
if err != nil {
return err
}
2024-07-15 07:32:00 +00:00
err = g . copyGrub ( )
2024-01-15 14:15:05 +00:00
if err != nil {
return err
}
2024-07-15 07:32:00 +00:00
// Add grub.cfg in EFI that chainloads the grub.cfg in state
// Notice that we set the config to /grub2/grub.cfg which means the above we need to copy the file from
// the installation source into that dir
grubCfgContent := [ ] byte ( fmt . Sprintf ( "search --no-floppy --label --set=root %s\nset prefix=($root)/%s\nconfigfile ($root)/%s/grub.cfg" , stateLabel , systemgrub , systemgrub ) )
err = g . config . Fs . WriteFile ( filepath . Join ( cnst . EfiDir , "EFI/boot/grub.cfg" ) , grubCfgContent , cnst . FilePerm )
if err != nil {
return fmt . Errorf ( "error writing %s: %s" , filepath . Join ( cnst . EfiDir , "EFI/boot/grub.cfg" ) , err )
}
// Ubuntu efi searches for the grub.cfg file under /EFI/ubuntu/grub.cfg while we store it under /boot/grub2/grub.cfg
// workaround this by copying it there as well
2024-10-07 09:44:05 +00:00
// read the kairos-release from the rootfs to know if we are creating a ubuntu based iso
2024-07-15 07:32:00 +00:00
if strings . Contains ( strings . ToLower ( flavor ) , "ubuntu" ) {
g . config . Logger . Infof ( "Ubuntu based ISO detected, copying grub.cfg to /EFI/ubuntu/grub.cfg" )
err = fsutils . MkdirAll ( g . config . Fs , filepath . Join ( cnst . EfiDir , "EFI/ubuntu/" ) , constants . DirPerm )
if err != nil {
g . config . Logger . Errorf ( "Failed writing grub.cfg: %v" , err )
return err
}
err = g . config . Fs . WriteFile ( filepath . Join ( cnst . EfiDir , "EFI/ubuntu/grub.cfg" ) , grubCfgContent , constants . FilePerm )
if err != nil {
g . config . Logger . Errorf ( "Failed writing grub.cfg: %v" , err )
return err
}
}
2024-01-15 14:15:05 +00:00
}
2023-05-05 16:43:21 +00:00
}
return nil
}
2025-04-25 08:43:21 +00:00
// findGrubDir will find the grub dir under the dir given if possible by searching for the modinfo.sh
// And it will return the full dir path where the modinfo.sh is contained
func findGrubDir ( vfs v1 . FS , dir string ) string {
var foundPath string
_ = fsutils . WalkDirFs ( vfs , dir , func ( path string , d fs . DirEntry , err error ) error {
if err != nil {
return err
}
if d . Name ( ) == "modinfo.sh" && strings . Contains ( path , "i386-pc" ) {
// We found the grub dir, return it
foundPath = filepath . Dir ( path )
return nil
}
return nil
} )
return foundPath
}
func SetPersistentVariables ( grubEnvFile string , vars map [ string ] string , c * agentConfig . Config ) error {
2024-09-10 10:23:15 +00:00
var b bytes . Buffer
2025-04-25 08:43:21 +00:00
// Write header
2024-09-10 10:23:15 +00:00
b . WriteString ( "# GRUB Environment Block\n" )
2025-04-25 08:43:21 +00:00
// First we need to read the existing values from the grubenv file if they exist
finalVars , err := ReadPersistentVariables ( grubEnvFile , c )
if err != nil {
if ! os . IsNotExist ( err ) {
return fmt . Errorf ( "error reading existing grubenv file %s: %s" , grubEnvFile , err )
}
}
// Check if we have a nil var
if len ( finalVars ) != 0 {
c . Logger . Logger . Debug ( ) . Interface ( "existingVars" , finalVars ) . Msg ( "Existing grubenv variables" )
}
// Merge the existing vars with the new ones
// existing vars will be overridden by the new ones from vars if they match
for key , newValue := range vars {
if oldValue , exists := finalVars [ key ] ; exists {
c . Logger . Logger . Warn ( ) . Str ( "key" , key ) . Str ( "oldValue" , oldValue ) . Str ( "newValue" , newValue ) . Msg ( "Overriding existing grubenv variable" )
}
finalVars [ key ] = newValue
}
keys := make ( [ ] string , 0 , len ( finalVars ) )
for k := range finalVars {
2024-09-10 10:23:15 +00:00
keys = append ( keys , k )
}
sort . Strings ( keys )
for _ , k := range keys {
2025-04-25 08:43:21 +00:00
if len ( finalVars [ k ] ) > 0 {
b . WriteString ( fmt . Sprintf ( "%s=%s\n" , k , finalVars [ k ] ) )
2023-05-05 16:43:21 +00:00
}
}
2024-09-10 10:23:15 +00:00
// https://www.gnu.org/software/grub/manual/grub/html_node/Environment-block.html
// It has to be exactly 1024bytes, if its not it has to be filled with #
toBeFilled := 1024 - b . Len ( ) % 1024
for i := 0 ; i < toBeFilled ; i ++ {
b . WriteByte ( '#' )
}
2025-04-25 08:43:21 +00:00
return c . Fs . WriteFile ( grubEnvFile , b . Bytes ( ) , cnst . FilePerm )
2023-05-05 16:43:21 +00:00
}
2023-09-13 09:07:28 +00:00
// copyGrubFonts will try to finds and copy the needed grub fonts into the system
// rootdir is the dir where to search for the fonts
// bootdir is the base dir where they will be copied
func copyGrubFonts ( cfg * agentConfig . Config , rootDir , bootDir , systemgrub string ) {
for _ , m := range constants . GetGrubFonts ( ) {
var foundFont bool
_ = fsutils . WalkDirFs ( cfg . Fs , rootDir , func ( path string , d fs . DirEntry , err error ) error {
if err != nil {
return err
}
if d . Name ( ) == m && strings . Contains ( path , cfg . Arch ) {
fileWriteName := filepath . Join ( bootDir , fmt . Sprintf ( "%s/%s-efi/fonts/%s" , systemgrub , cfg . Arch , m ) )
cfg . Logger . Debugf ( "Copying %s to %s" , path , fileWriteName )
fileContent , err := cfg . Fs . ReadFile ( path )
if err != nil {
return fmt . Errorf ( "error reading %s: %s" , path , err )
}
err = cfg . Fs . WriteFile ( fileWriteName , fileContent , cnst . FilePerm )
if err != nil {
return fmt . Errorf ( "error writing %s: %s" , fileWriteName , err )
}
foundFont = true
return nil
}
return err
} )
if ! foundFont {
// Not a real error as to fail install but a big warning
cfg . Logger . Warnf ( "did not find grub font %s under %s" , m , rootDir )
}
}
}
2024-01-11 10:24:43 +00:00
func ( g Grub ) copyShim ( ) error {
shimFiles := utils . GetEfiShimFiles ( g . config . Arch )
shimDone := false
for _ , f := range shimFiles {
_ , err := g . config . Fs . Stat ( filepath . Join ( cnst . ActiveDir , f ) )
if err != nil {
g . config . Logger . Debugf ( "skip copying %s: not found" , filepath . Join ( cnst . ActiveDir , f ) )
continue
}
_ , name := filepath . Split ( f )
// remove the .signed suffix if present
2024-01-11 10:51:46 +00:00
name = strings . TrimSuffix ( name , ".signed" )
2024-01-11 10:24:43 +00:00
fileWriteName := filepath . Join ( cnst . EfiDir , fmt . Sprintf ( "EFI/boot/%s" , name ) )
g . config . Logger . Debugf ( "Copying %s to %s" , f , fileWriteName )
// Try to find the paths give until we succeed
fileContent , err := g . config . Fs . ReadFile ( filepath . Join ( cnst . ActiveDir , f ) )
if err != nil {
g . config . Logger . Warnf ( "error reading %s: %s" , filepath . Join ( cnst . ActiveDir , f ) , err )
continue
}
err = g . config . Fs . WriteFile ( fileWriteName , fileContent , cnst . FilePerm )
if err != nil {
return fmt . Errorf ( "error writing %s: %s" , fileWriteName , err )
}
shimDone = true
// Copy the shim content to the fallback name so the system boots from fallback. This means that we do not create
// any bootloader entries, so our recent installation has the lower priority if something else is on the bootloader
writeShim := cnst . GetFallBackEfi ( g . config . Arch )
err = g . config . Fs . WriteFile ( filepath . Join ( cnst . EfiDir , "EFI/boot/" , writeShim ) , fileContent , cnst . FilePerm )
if err != nil {
return fmt . Errorf ( "could not write shim file %s at dir %s" , writeShim , cnst . EfiDir )
}
break
}
if ! shimDone {
g . config . Logger . Debugf ( "List of shim files searched for: %s" , shimFiles )
return fmt . Errorf ( "could not find any shim file to copy" )
}
return nil
}
func ( g Grub ) copyGrub ( ) error {
grubFiles := utils . GetEfiGrubFiles ( g . config . Arch )
grubDone := false
for _ , f := range grubFiles {
_ , err := g . config . Fs . Stat ( filepath . Join ( constants . ActiveDir , f ) )
if err != nil {
g . config . Logger . Debugf ( "skip copying %s: not found" , filepath . Join ( constants . ActiveDir , f ) )
continue
}
_ , name := filepath . Split ( f )
// remove the .signed suffix if present
2024-01-11 10:51:46 +00:00
name = strings . TrimSuffix ( name , ".signed" )
2024-01-11 10:24:43 +00:00
fileWriteName := filepath . Join ( cnst . EfiDir , fmt . Sprintf ( "EFI/boot/%s" , name ) )
g . config . Logger . Debugf ( "Copying %s to %s" , f , fileWriteName )
// Try to find the paths give until we succeed
fileContent , err := g . config . Fs . ReadFile ( filepath . Join ( cnst . ActiveDir , f ) )
if err != nil {
g . config . Logger . Warnf ( "error reading %s: %s" , filepath . Join ( cnst . ActiveDir , f ) , err )
continue
}
err = g . config . Fs . WriteFile ( fileWriteName , fileContent , cnst . FilePerm )
if err != nil {
return fmt . Errorf ( "error writing %s: %s" , fileWriteName , err )
}
grubDone = true
break
}
if ! grubDone {
g . config . Logger . Debugf ( "List of grub files searched for: %s" , grubFiles )
return fmt . Errorf ( "could not find any grub efi file to copy" )
}
return nil
}
2024-09-10 10:23:15 +00:00
// ReadPersistentVariables will read a grub env file and parse the values
2025-04-25 08:43:21 +00:00
func ReadPersistentVariables ( grubEnvFile string , c * agentConfig . Config ) ( map [ string ] string , error ) {
2024-09-10 10:23:15 +00:00
vars := make ( map [ string ] string )
2025-04-25 08:43:21 +00:00
f , err := c . Fs . ReadFile ( grubEnvFile )
2024-09-10 10:23:15 +00:00
if err != nil {
2025-04-25 08:43:21 +00:00
return vars , err
2024-09-10 10:23:15 +00:00
}
for _ , a := range strings . Split ( string ( f ) , "\n" ) {
// comment or fillup, so skip
if strings . HasPrefix ( a , "#" ) {
continue
}
splitted := strings . Split ( a , "=" )
if len ( splitted ) == 2 {
vars [ splitted [ 0 ] ] = splitted [ 1 ]
} else {
2025-04-25 08:43:21 +00:00
return vars , fmt . Errorf ( "invalid format for %s" , a )
2024-09-10 10:23:15 +00:00
}
}
return vars , nil
}