Files
snapd/boot/cmdline.go
Samuele Pedroni c61caff7fd many: avoid automatic system restarts on classic through new overlord/restart logic (#12228)
* o/state: fix pendingChangeByAttr initialisation when loading state

* o/restart,o/state: introduce restart.FinishTaskWithRestart

this combines setting the status of a task being finished and requesting a
restart

it will also replace rebooting the system on classic with setting the task into
HoldStatus until a manual system restart occurs

moving the task from HoldStatus to DoneStatus is implemented with
RestartManager.StartUp logic based on extra state attributes to track reboot on
such task and its change

to avoid such pending changes to be pruned prematurely a predicate
RestartManager.PendingForSystemRestart is registered with
State.RegisterPendingChangeByAttr

* overlord: delegate from snapstate.FinishTaskWithRestart to restart

this gives us the right behavior on classic now

adjust tests to the expected behavior, fixing previous XXXs

* o/snapstate: switch around NoErrorOrHold to IsErrAndNotHold

this way fewer places need to negate it

thanks MiguelPires

* o/restart: simplify things a bit turning held-for-system-restart into just a flag

on the Change, instead of a full counter

* o/restart: try to clarify FinishTaskWithRestart behavior in comment

* o/state: do not attempt to run tasks in WaitStatus

the lack of this logic was causing a panic,
given that for now we expect the new status to be used
only on new system revert is not an issue. But if we started
using WaitStatus in preexisting system/scenarios we would
have to think how to deal with this backward incompatibility

* overlord: switch logic to use WaitStatus instead of HoldStatus

* tests: enable kernel refresh testing

the gadget case is still not working, needs more investigation

* tests/fde-on-classic: re-enable test for gadget updates

which was failing because two reboots were happening as the kernel
command line from the manually written grub.cfg was not matching the
one shipped by snapd.

* tests/fde-on-classic: make sure that modeenv kernel command line

matches the one in snapd/grub.cfg.

* tests: check in fde-on-classic test that remote host has no reboot scheduled

* boot: add debug about cmdline updates

* tests: cherry pick https://github.com/snapcore/snapd-testing-tools/pull/33

* tests: update nested fde-on-classic cmdline to match what grub-recovery.cfg writes

Co-authored-by: Alfonso Sánchez-Beato <alfonso.sanchez-beato@canonical.com>
Co-authored-by: Michael Vogt <mvo@ubuntu.com>
2022-11-17 09:39:17 +01:00

395 lines
14 KiB
Go

// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2021 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package boot
import (
"errors"
"fmt"
"github.com/snapcore/snapd/asserts"
"github.com/snapcore/snapd/bootloader"
"github.com/snapcore/snapd/dirs"
"github.com/snapcore/snapd/gadget"
"github.com/snapcore/snapd/logger"
"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/strutil"
)
const (
// ModeRun indicates the regular operating system mode of the device.
ModeRun = "run"
// ModeInstall is a mode in which a new system is installed on the
// device.
ModeInstall = "install"
// ModeRecover is a mode in which the device boots into the recovery
// system.
ModeRecover = "recover"
// ModeFactoryReset is a mode in which the device performs a factory
// reset.
ModeFactoryReset = "factory-reset"
// ModeRunCVM is Azure CVM specific run mode fde + classic debs
ModeRunCVM = "cloudimg-rootfs"
)
var (
validModes = []string{ModeInstall, ModeRecover, ModeFactoryReset, ModeRun, ModeRunCVM}
)
// ModeAndRecoverySystemFromKernelCommandLine returns the current system mode
// and the recovery system label as passed in the kernel command line by the
// bootloader.
func ModeAndRecoverySystemFromKernelCommandLine() (mode, sysLabel string, err error) {
m, err := osutil.KernelCommandLineKeyValues("snapd_recovery_mode", "snapd_recovery_system")
if err != nil {
return "", "", err
}
var modeOk bool
mode, modeOk = m["snapd_recovery_mode"]
// no mode specified gets interpreted as install
if modeOk {
if mode == "" {
mode = ModeInstall
} else if !strutil.ListContains(validModes, mode) {
return "", "", fmt.Errorf("cannot use unknown mode %q", mode)
}
}
sysLabel = m["snapd_recovery_system"]
switch {
case mode == "" && sysLabel == "":
return "", "", fmt.Errorf("cannot detect mode nor recovery system to use")
case mode == "" && sysLabel != "":
return "", "", fmt.Errorf("cannot specify system label without a mode")
case mode == ModeInstall && sysLabel == "":
return "", "", fmt.Errorf("cannot specify install mode without system label")
case mode == ModeRun && sysLabel != "":
// XXX: should we silently ignore the label? at least log for now
logger.Noticef(`ignoring recovery system label %q in "run" mode`, sysLabel)
sysLabel = ""
}
return mode, sysLabel, nil
}
var errBootConfigNotManaged = errors.New("boot config is not managed")
func getBootloaderManagingItsAssets(where string, opts *bootloader.Options) (bootloader.TrustedAssetsBootloader, error) {
bl, err := bootloader.Find(where, opts)
if err != nil {
return nil, fmt.Errorf("internal error: cannot find trusted assets bootloader under %q: %v", where, err)
}
mbl, ok := bl.(bootloader.TrustedAssetsBootloader)
if !ok {
// the bootloader cannot manage its scripts
return nil, errBootConfigNotManaged
}
return mbl, nil
}
// bootVarsForTrustedCommandLineFromGadget returns a set of boot variables that
// carry the command line arguments requested by the gadget. This is only useful
// if snapd is managing the boot config.
func bootVarsForTrustedCommandLineFromGadget(gadgetDirOrSnapPath string) (map[string]string, error) {
extraOrFull, full, err := gadget.KernelCommandLineFromGadget(gadgetDirOrSnapPath)
if err != nil {
if err == gadget.ErrNoKernelCommandline {
// nothing set by the gadget, but we could have had
// arguments before, so make sure those are cleared now
clear := map[string]string{
"snapd_extra_cmdline_args": "",
"snapd_full_cmdline_args": "",
}
return clear, nil
}
return nil, fmt.Errorf("cannot use kernel command line from gadget: %v", err)
}
// gadget has the kernel command line
args := map[string]string{
"snapd_extra_cmdline_args": "",
"snapd_full_cmdline_args": "",
}
if full {
args["snapd_full_cmdline_args"] = extraOrFull
} else {
args["snapd_extra_cmdline_args"] = extraOrFull
}
return args, nil
}
const (
currentEdition = iota
candidateEdition
)
func composeCommandLine(currentOrCandidate int, mode, system, gadgetDirOrSnapPath string) (string, error) {
if mode != ModeRun && mode != ModeRecover && mode != ModeFactoryReset {
return "", fmt.Errorf("internal error: unsupported command line mode %q", mode)
}
// get the run mode bootloader under the native run partition layout
opts := &bootloader.Options{
Role: bootloader.RoleRunMode,
NoSlashBoot: true,
}
bootloaderRootDir := InitramfsUbuntuBootDir
components := bootloader.CommandLineComponents{
ModeArg: "snapd_recovery_mode=run",
}
if mode == ModeRecover || mode == ModeFactoryReset {
if system == "" {
return "", fmt.Errorf("internal error: system is unset")
}
// dealing with recovery system bootloader
opts.Role = bootloader.RoleRecovery
bootloaderRootDir = InitramfsUbuntuSeedDir
// recovery mode & system command line arguments
modeArg := "snapd_recovery_mode=recover"
if mode == ModeFactoryReset {
modeArg = "snapd_recovery_mode=factory-reset"
}
components = bootloader.CommandLineComponents{
ModeArg: modeArg,
SystemArg: fmt.Sprintf("snapd_recovery_system=%v", system),
}
}
mbl, err := getBootloaderManagingItsAssets(bootloaderRootDir, opts)
if err != nil {
if err == errBootConfigNotManaged {
return "", nil
}
return "", err
}
if gadgetDirOrSnapPath != "" {
extraOrFull, full, err := gadget.KernelCommandLineFromGadget(gadgetDirOrSnapPath)
if err != nil && err != gadget.ErrNoKernelCommandline {
return "", fmt.Errorf("cannot use kernel command line from gadget: %v", err)
}
if err == nil {
// gadget provides some part of the kernel command line
if full {
components.FullArgs = extraOrFull
} else {
components.ExtraArgs = extraOrFull
}
}
}
if currentOrCandidate == currentEdition {
return mbl.CommandLine(components)
} else {
return mbl.CandidateCommandLine(components)
}
}
// ComposeRecoveryCommandLine composes the kernel command line used when booting
// a given system in recover mode.
func ComposeRecoveryCommandLine(model *asserts.Model, system, gadgetDirOrSnapPath string) (string, error) {
if model.Grade() == asserts.ModelGradeUnset {
return "", nil
}
return composeCommandLine(currentEdition, ModeRecover, system, gadgetDirOrSnapPath)
}
// ComposeCommandLine composes the kernel command line used when booting the
// system in run mode.
func ComposeCommandLine(model *asserts.Model, gadgetDirOrSnapPath string) (string, error) {
if model.Grade() == asserts.ModelGradeUnset {
return "", nil
}
return composeCommandLine(currentEdition, ModeRun, "", gadgetDirOrSnapPath)
}
// ComposeCandidateCommandLine composes the kernel command line used when
// booting the system in run mode with the current built-in edition of managed
// boot assets.
func ComposeCandidateCommandLine(model *asserts.Model, gadgetDirOrSnapPath string) (string, error) {
if model.Grade() == asserts.ModelGradeUnset {
return "", nil
}
return composeCommandLine(candidateEdition, ModeRun, "", gadgetDirOrSnapPath)
}
// ComposeCandidateRecoveryCommandLine composes the kernel command line used
// when booting the given system in recover mode with the current built-in
// edition of managed boot assets.
func ComposeCandidateRecoveryCommandLine(model *asserts.Model, system, gadgetDirOrSnapPath string) (string, error) {
if model.Grade() == asserts.ModelGradeUnset {
return "", nil
}
return composeCommandLine(candidateEdition, ModeRecover, system, gadgetDirOrSnapPath)
}
// observeSuccessfulCommandLine observes a successful boot with a command line
// and takes an action based on the contents of the modeenv. The current kernel
// command lines in the modeenv can have up to 2 entries when the managed
// bootloader boot config gets updated.
func observeSuccessfulCommandLine(model *asserts.Model, m *Modeenv) (*Modeenv, error) {
// TODO:UC20 only care about run mode for now
if m.Mode != "run" {
return m, nil
}
switch len(m.CurrentKernelCommandLines) {
case 0:
// maybe a compatibility scenario, no command lines tracked in
// modeenv yet, this can happen when having booted with a newer
// snapd
return observeSuccessfulCommandLineCompatBoot(model, m)
case 1:
// no command line update
return m, nil
default:
return observeSuccessfulCommandLineUpdate(m)
}
}
// observeSuccessfulCommandLineUpdate observes a successful boot with a command
// line which is expected to be listed among the current kernel command line
// entries carried in the modeenv. One of those entries must match the current
// kernel command line of a running system and will be recorded alone as in use.
func observeSuccessfulCommandLineUpdate(m *Modeenv) (*Modeenv, error) {
newM, err := m.Copy()
if err != nil {
return nil, err
}
// get the current command line
cmdlineBootedWith, err := osutil.KernelCommandLine()
if err != nil {
return nil, err
}
if !strutil.ListContains([]string(m.CurrentKernelCommandLines), cmdlineBootedWith) {
return nil, fmt.Errorf("current command line content %q not matching any expected entry",
cmdlineBootedWith)
}
newM.CurrentKernelCommandLines = bootCommandLines{cmdlineBootedWith}
return newM, nil
}
// observeSuccessfulCommandLineCompatBoot observes a successful boot with a
// kernel command line, where the list of current kernel command lines in the
// modeenv is unpopulated. This handles a compatibility scenario with systems
// that were installed using a previous version of snapd. It verifies that the
// expected kernel command line matches the one the system booted with and
// populates modeenv kernel command line list accordingly.
func observeSuccessfulCommandLineCompatBoot(model *asserts.Model, m *Modeenv) (*Modeenv, error) {
// since this is a compatibility scenario, the kernel command line
// arguments would not have come from the gadget before either
cmdlineExpected, err := ComposeCommandLine(model, "")
if err != nil {
return nil, err
}
if cmdlineExpected == "" {
// there is no particular command line expected for this model
// and system bootloader, indicating that the command line is
// not being tracked
return m, nil
}
cmdlineBootedWith, err := osutil.KernelCommandLine()
if err != nil {
return nil, err
}
if cmdlineExpected != cmdlineBootedWith {
return nil, fmt.Errorf("unexpected current command line: %q", cmdlineBootedWith)
}
newM, err := m.Copy()
if err != nil {
return nil, err
}
newM.CurrentKernelCommandLines = bootCommandLines{cmdlineExpected}
return newM, nil
}
type commandLineUpdateReason int
const (
commandLineUpdateReasonSnapd commandLineUpdateReason = iota
commandLineUpdateReasonGadget
)
// observeCommandLineUpdate observes a pending kernel command line change caused
// by an update of boot config or the gadget snap. When needed, the modeenv is
// updated with a candidate command line and the encryption keys are resealed.
// This helper should be called right before updating the managed boot config.
func observeCommandLineUpdate(model *asserts.Model, reason commandLineUpdateReason, gadgetSnapOrDir string) (updated bool, err error) {
// TODO:UC20: consider updating a recovery system command line
m, err := loadModeenv()
if err != nil {
return false, err
}
if len(m.CurrentKernelCommandLines) == 0 {
return false, fmt.Errorf("internal error: current kernel command lines is unset")
}
// this is the current expected command line which was recorded by
// bootstate
cmdline := m.CurrentKernelCommandLines[0]
// this is the new expected command line
var candidateCmdline string
switch reason {
case commandLineUpdateReasonSnapd:
// pending boot config update
candidateCmdline, err = ComposeCandidateCommandLine(model, gadgetSnapOrDir)
case commandLineUpdateReasonGadget:
// pending gadget update
candidateCmdline, err = ComposeCommandLine(model, gadgetSnapOrDir)
}
if err != nil {
return false, err
}
if cmdline == candidateCmdline {
// command line is the same or no actual change in modeenv
return false, nil
}
logger.Debugf("kernel commandline changes from %q to %q", cmdline, candidateCmdline)
// actual change of the command line content
m.CurrentKernelCommandLines = bootCommandLines{cmdline, candidateCmdline}
if err := m.Write(); err != nil {
return false, err
}
expectReseal := true
if err := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal); err != nil {
return false, err
}
return true, nil
}
// kernelCommandLinesForResealWithFallback provides the list of kernel command
// lines for use during reseal. During normal operation, the command lines will
// be listed in the modeenv.
func kernelCommandLinesForResealWithFallback(modeenv *Modeenv) (cmdlines []string, err error) {
if len(modeenv.CurrentKernelCommandLines) > 0 {
return modeenv.CurrentKernelCommandLines, nil
}
// fallback for when reseal is called before mark boot successful set a
// default during snapd update, since this is a compatibility scenario
// there would be no kernel command lines arguments coming from the
// gadget either
gadgetDir := ""
cmdline, err := composeCommandLine(currentEdition, ModeRun, "", gadgetDir)
if err != nil {
return nil, err
}
return []string{cmdline}, nil
}