Skip to content

Commit

Permalink
many: add API routes for creating/removing recovery systems (canonica…
Browse files Browse the repository at this point in the history
…l#13651)

* o/assertstate, o/devicestate: add more general function for fetching validation set assertions

* daemon, client: add API routes for creating/removing recovery system

* daemon, o/snapstate: add .snap file extension to snaps from forms

The seed writer will fail to consider files as snaps if their filenames
do not end in .snap.

* tests: test creating a recovery system

* tests: add spread test for offline creation of recovery system

* tests: update offline recovery system test to reboot into new system

* tests/nested/manual/recovery-system-reboot: add variants for factory-reset and install modes

* tests: replace usage of default-recovery-system with default-recovery

* o/devicestate: enable offline creation of recovery system entirely from pre-installed snaps

* daemon, client: test that offline API works without providing snaps or validation sets

* tests/nested/manual/recovery-system-offline: test offline remodel with only pre-installed snaps

* tests/nested/manual/recovery-system-reboot: modify test to create system with new set of essential snaps

* tests: disable shellcheck printf check

* daemon: rename functions for working with form values and add one for working with booleans

* daemon: acquire state lock later in postSystemActionCreateOffline

* daemon: cleanup form files if we fail to make change to create a recovery system

* daemon: rename parseValidationSets to assertionsFromValidationSetStrings for clarity

* client, daemon, tests: add "offline" field to create recovery system JSON api

* daemon: convert TODO about comma-delimited list into explanation of why we use a comma delimited list

* NEWS.md: add mention of create/remove recovery systems API

* tests/nested/manual/recovery-system-offline: explicitly disable network from nested vm

* tests/nested/manual/recovery-system-reboot: do not use new gadget in recovery system for now

* tests/lib/nested.sh: add variable NESTED_FORCE_MS_KEYS to force using microsoft keys

* tests/nested/manual/recovery-system-reboot: add back gadget snap swap to test

* tests/nested/manual/recovery-system-reboot: retry POST to remove since there might be an auto-refresh happening
  • Loading branch information
andrewphelpsj authored Mar 10, 2024
1 parent f079986 commit c57901e
Show file tree
Hide file tree
Showing 22 changed files with 1,868 additions and 52 deletions.
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* state: add support for notices (from pebble)
* daemon: add notices to the snapd API under `/v2/notices` and `/v2/notice`
* Mandatory device cgroup for all snaps using bare and core24 base as well as future bases
* Added API route for creating recovery systems: POST to `/v2/systems` with action `create`
* Added API route for removing recovery systems: POST to `/v2/systems/{label}` with action `remove`

# New in snapd 2.61.3:
* Install systemd files in correct location for 24.04
Expand Down
19 changes: 19 additions & 0 deletions client/systems.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,22 @@ func (client *Client) InstallSystem(systemLabel string, opts *InstallSystemOptio
}
return chgID, nil
}

// CreateSystemOptions contains the options for creating a new recovery system.
type CreateSystemOptions struct {
// Label is the label of the new system.
Label string `json:"label,omitempty"`
// ValidationSets is a list of validation sets that snaps in the newly
// created system should be validated against.
ValidationSets []string `json:"validation-sets,omitempty"`
// TestSystem is true if the system should be tested by rebooting into the
// new system.
TestSystem bool `json:"test-system,omitempty"`
// MarkDefault is true if the system should be marked as the default
// recovery system.
MarkDefault bool `json:"mark-default,omitempty"`
// Offline is true if the system should be created without reaching out to
// the store. In the JSON variant of the API, only pre-installed
// snaps/assertions will be considered.
Offline bool `json:"offline,omitempty"`
}
3 changes: 3 additions & 0 deletions daemon/api_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ func remodelForm(c *Command, r *http.Request, contentTypeParams map[string]strin

// we are in charge of the temp files, until they're handed off to the change
var pathsToNotRemove []string

// TODO: temp files are not removed if devicestate.Remodel returns an error
// right now. change this to work how postSystemsActionForm does it.
defer func() {
form.RemoveAllExcept(pathsToNotRemove)
}()
Expand Down
6 changes: 4 additions & 2 deletions daemon/api_sideload_n_try.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"os"
"path/filepath"
Expand Down Expand Up @@ -63,6 +62,9 @@ type FileReference struct {
TmpPath string
}

// RemoveAllExcept removes all temporary files uploaded with form, except for
// the given paths. Should be called once the files uploaded with the form are
// no longer needed.
func (f *Form) RemoveAllExcept(paths []string) {
for _, refs := range f.FileRefs {
for _, ref := range refs {
Expand Down Expand Up @@ -527,7 +529,7 @@ func readForm(reader *multipart.Reader) (_ *Form, apiErr *apiError) {
// its path. If the path is not empty then a file was written and it's the
// caller's responsibility to clean it up (even if the error is non-nil).
func writeToTempFile(reader io.Reader) (path string, err error) {
tmpf, err := ioutil.TempFile(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix)
tmpf, err := os.CreateTemp(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix+"*.snap")
if err != nil {
return "", fmt.Errorf("cannot create temp file for form data file part: %v", err)
}
Expand Down
284 changes: 284 additions & 0 deletions daemon/api_systems.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,23 @@ package daemon

import (
"encoding/json"
"errors"
"mime"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"

"github.com/snapcore/snapd/asserts"
"github.com/snapcore/snapd/asserts/snapasserts"
"github.com/snapcore/snapd/client"
"github.com/snapcore/snapd/gadget"
"github.com/snapcore/snapd/overlord/assertstate"
"github.com/snapcore/snapd/overlord/auth"
"github.com/snapcore/snapd/overlord/devicestate"
"github.com/snapcore/snapd/overlord/install"
"github.com/snapcore/snapd/release"
"github.com/snapcore/snapd/snap"
)

Expand Down Expand Up @@ -139,6 +147,8 @@ func storageEncryption(encInfo *install.EncryptionSupportInfo) *client.StorageEn
var (
devicestateInstallFinish = devicestate.InstallFinish
devicestateInstallSetupStorageEncryption = devicestate.InstallSetupStorageEncryption
devicestateCreateRecoverySystem = devicestate.CreateRecoverySystem
devicestateRemoveRecoverySystem = devicestate.RemoveRecoverySystem
)

func getSystemDetails(c *Command, r *http.Request, user *auth.UserState) Response {
Expand Down Expand Up @@ -180,9 +190,55 @@ type systemActionRequest struct {

client.SystemAction
client.InstallSystemOptions
client.CreateSystemOptions
}

func postSystemsAction(c *Command, r *http.Request, user *auth.UserState) Response {
contentType := r.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "application/json"
}

switch mediaType {
case "application/json":
return postSystemsActionJSON(c, r)
case "multipart/form-data":
return postSystemsActionForm(c, r, params)
default:
return BadRequest("unexpected media type %q", mediaType)
}
}

func postSystemsActionForm(c *Command, r *http.Request, contentTypeParams map[string]string) (res Response) {
boundary := contentTypeParams["boundary"]
mpReader := multipart.NewReader(r.Body, boundary)
form, errRsp := readForm(mpReader)
if errRsp != nil {
return errRsp
}

action := form.Values["action"]
if len(action) != 1 {
return BadRequest("expected exactly one action in form")
}

defer func() {
// remove all files associated with the form if we're returning an error
if _, ok := res.(*apiError); ok {
form.RemoveAllExcept(nil)
}
}()

switch action[0] {
case "create":
return postSystemActionCreateOffline(c, form)
default:
return BadRequest("%s action is not supported for content type multipart/form-data", action[0])
}
}

func postSystemsActionJSON(c *Command, r *http.Request) Response {
var req systemActionRequest
systemLabel := muxVars(r)["label"]

Expand All @@ -200,6 +256,13 @@ func postSystemsAction(c *Command, r *http.Request, user *auth.UserState) Respon
return postSystemActionReboot(c, systemLabel, &req)
case "install":
return postSystemActionInstall(c, systemLabel, &req)
case "create":
if systemLabel != "" {
return BadRequest("label should not be provided in route when creating a system")
}
return postSystemActionCreate(c, &req)
case "remove":
return postSystemActionRemove(c, systemLabel)
default:
return BadRequest("unsupported action %q", req.Action)
}
Expand Down Expand Up @@ -272,3 +335,224 @@ func postSystemActionInstall(c *Command, systemLabel string, req *systemActionRe
return BadRequest("unsupported install step %q", req.Step)
}
}

func assertionsFromValidationSetStrings(validationSets []string) ([]*asserts.AtSequence, error) {
sets := make([]*asserts.AtSequence, 0, len(validationSets))
for _, vs := range validationSets {
account, name, seq, err := snapasserts.ParseValidationSet(vs)
if err != nil {
return nil, err
}

assertion := asserts.AtSequence{
Type: asserts.ValidationSetType,
SequenceKey: []string{release.Series, account, name},
Pinned: seq > 0,
Sequence: seq,
Revision: asserts.RevisionNotKnown,
}

sets = append(sets, &assertion)
}

return sets, nil
}

func readFormValue(form *Form, key string) (string, *apiError) {
values := form.Values[key]
if len(values) != 1 {
return "", BadRequest("expected exactly one %q value in form", key)
}
return values[0], nil
}

func readOptionalFormValue(form *Form, key string, defaultValue string) (string, *apiError) {
values := form.Values[key]
switch len(values) {
case 0:
return defaultValue, nil
case 1:
return values[0], nil
default:
return "", BadRequest("expected at most one %q value in form", key)
}
}

func readOptionalFormBoolean(form *Form, key string, defaultValue bool) (bool, *apiError) {
values := form.Values[key]
switch len(values) {
case 0:
return defaultValue, nil
case 1:
b, err := strconv.ParseBool(values[0])
if err != nil {
return false, BadRequest("cannot parse %q value as boolean: %s", key, values[0])
}
return b, nil
default:
return false, BadRequest("expected at most one %q value in form", key)
}
}

func postSystemActionCreateOffline(c *Command, form *Form) Response {
label, errRsp := readFormValue(form, "label")
if errRsp != nil {
return errRsp
}

testSystem, errRsp := readOptionalFormBoolean(form, "test-system", false)
if errRsp != nil {
return errRsp
}

markDefault, errRsp := readOptionalFormBoolean(form, "mark-default", false)
if errRsp != nil {
return errRsp
}

vsetsList, errRsp := readOptionalFormValue(form, "validation-sets", "")
if errRsp != nil {
return errRsp
}

var splitVSets []string
if vsetsList != "" {
splitVSets = strings.Split(vsetsList, ",")
}

// this could be multiple "validation-set" values, but that would make it so
// that the field names in the form and JSON APIs are different, since the
// JSON API uses "validation-sets" (plural). to keep the APIs consistent, we
// use a comma-delimeted list of validation sets strings.
sequences, err := assertionsFromValidationSetStrings(splitVSets)
if err != nil {
return BadRequest("cannot parse validation sets: %v", err)
}

var snapFiles []*uploadedSnap
if len(form.FileRefs["snap"]) > 0 {
snaps, errRsp := form.GetSnapFiles()
if errRsp != nil {
return errRsp
}

snapFiles = snaps
}

batch := asserts.NewBatch(nil)
for _, a := range form.Values["assertion"] {
if _, err := batch.AddStream(strings.NewReader(a)); err != nil {
return BadRequest("cannot decode assertion: %v", err)
}
}

st := c.d.overlord.State()
st.Lock()
defer st.Unlock()

if err := assertstate.AddBatch(st, batch, &asserts.CommitOptions{Precheck: true}); err != nil {
return BadRequest("error committing assertions: %v", err)
}

validationSets, err := assertstate.FetchValidationSets(st, sequences, assertstate.FetchValidationSetsOptions{
Offline: true,
}, nil)
if err != nil {
return BadRequest("cannot find validation sets in db: %v", err)
}

slInfo, apiErr := sideloadSnapsInfo(st, snapFiles, sideloadFlags{})
if apiErr != nil {
return apiErr
}

if len(slInfo.sideInfos) != len(slInfo.tmpPaths) {
return InternalError("mismatch between number of snap side infos and temporary paths")
}

localSnaps := make([]devicestate.LocalSnap, 0, len(slInfo.sideInfos))
for i := range slInfo.sideInfos {
localSnaps = append(localSnaps, devicestate.LocalSnap{
SideInfo: slInfo.sideInfos[i],
Path: slInfo.tmpPaths[i],
})
}

chg, err := devicestateCreateRecoverySystem(st, label, devicestate.CreateRecoverySystemOptions{
ValidationSets: validationSets.Sets(),
LocalSnaps: localSnaps,
TestSystem: testSystem,
MarkDefault: markDefault,
// using the form-based API implies that this should be an offline operation
Offline: true,
})
if err != nil {
return InternalError("cannot create recovery system %q: %v", label[0], err)
}

ensureStateSoon(st)

return AsyncResponse(nil, chg.ID())
}

func postSystemActionCreate(c *Command, req *systemActionRequest) Response {
st := c.d.overlord.State()
st.Lock()
defer st.Unlock()

if req.Label == "" {
return BadRequest("label must be provided in request body for action %q", req.Action)
}

sequences, err := assertionsFromValidationSetStrings(req.ValidationSets)
if err != nil {
return BadRequest("cannot parse validation sets: %v", err)
}

validationSets, err := assertstate.FetchValidationSets(c.d.state, sequences, assertstate.FetchValidationSetsOptions{
Offline: req.Offline,
}, nil)
if err != nil {
if errors.Is(err, &asserts.NotFoundError{}) {
return BadRequest("cannot fetch validation sets: %v", err)
}
return InternalError("cannot fetch validation sets: %v", err)
}

chg, err := devicestateCreateRecoverySystem(st, req.Label, devicestate.CreateRecoverySystemOptions{
ValidationSets: validationSets.Sets(),
TestSystem: req.TestSystem,
MarkDefault: req.MarkDefault,
Offline: req.Offline,
})
if err != nil {
return InternalError("cannot create recovery system %q: %v", req.Label, err)
}

ensureStateSoon(st)

return AsyncResponse(nil, chg.ID())
}

func postSystemActionRemove(c *Command, systemLabel string) Response {
if systemLabel == "" {
return BadRequest("system action requires the system label to be provided")
}

st := c.d.overlord.State()
st.Lock()
defer st.Unlock()

chg, err := devicestateRemoveRecoverySystem(st, systemLabel)
if err != nil {
if errors.Is(err, devicestate.ErrNoRecoverySystem) {
return NotFound(err.Error())
}

return InternalError("cannot remove recovery system %q: %v", systemLabel, err)
}

ensureStateSoon(st)

return AsyncResponse(nil, chg.ID())
}
Loading

0 comments on commit c57901e

Please sign in to comment.