From c57901e7054815d9e8dd8e7ed7d3b53ad726bd07 Mon Sep 17 00:00:00 2001 From: Andrew Phelps <136256549+andrewphelpsj@users.noreply.github.com> Date: Sun, 10 Mar 2024 16:35:04 -0400 Subject: [PATCH] many: add API routes for creating/removing recovery systems (#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 --- NEWS.md | 2 + client/systems.go | 19 + daemon/api_model.go | 3 + daemon/api_sideload_n_try.go | 6 +- daemon/api_systems.go | 284 +++++++ daemon/api_systems_test.go | 694 ++++++++++++++++++ daemon/export_api_systems_test.go | 12 + overlord/assertstate/assertstate.go | 26 +- overlord/assertstate/assertstate_test.go | 120 ++- overlord/devicestate/devicestate.go | 13 +- .../devicestate/devicestate_systems_test.go | 137 ++++ overlord/snapstate/snapstate_test.go | 4 +- spread.yaml | 1 + .../test-snapd-recovery-system-pc-20.json | 53 ++ .../test-snapd-recovery-system-pc-20.model | 61 ++ .../test-snapd-recovery-system-pc-22.json | 53 ++ .../test-snapd-recovery-system-pc-22.model | 61 ++ .../test-snapd-recovery-system-pinned.assert | 44 ++ tests/lib/nested.sh | 2 +- .../manual/recovery-system-offline/task.yaml | 110 +++ .../manual/recovery-system-reboot/task.yaml | 118 +++ tests/nested/manual/recovery-system/task.yaml | 97 +++ 22 files changed, 1868 insertions(+), 52 deletions(-) create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-20.json create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-20.model create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-22.json create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-22.model create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pinned.assert create mode 100644 tests/nested/manual/recovery-system-offline/task.yaml create mode 100644 tests/nested/manual/recovery-system-reboot/task.yaml create mode 100644 tests/nested/manual/recovery-system/task.yaml diff --git a/NEWS.md b/NEWS.md index be0fcf687d2..00a0180f042 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 diff --git a/client/systems.go b/client/systems.go index ef00b8c7485..47064270106 100644 --- a/client/systems.go +++ b/client/systems.go @@ -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"` +} diff --git a/daemon/api_model.go b/daemon/api_model.go index 4826bcf6e78..e13c7b5fb9e 100644 --- a/daemon/api_model.go +++ b/daemon/api_model.go @@ -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) }() diff --git a/daemon/api_sideload_n_try.go b/daemon/api_sideload_n_try.go index 0525273c8e6..37cc41f1666 100644 --- a/daemon/api_sideload_n_try.go +++ b/daemon/api_sideload_n_try.go @@ -25,7 +25,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "mime/multipart" "os" "path/filepath" @@ -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 { @@ -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) } diff --git a/daemon/api_systems.go b/daemon/api_systems.go index ecdb1d080e0..00d14c6f22c 100644 --- a/daemon/api_systems.go +++ b/daemon/api_systems.go @@ -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" ) @@ -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 { @@ -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"] @@ -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) } @@ -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()) +} diff --git a/daemon/api_systems_test.go b/daemon/api_systems_test.go index df2151f9d31..8cdd4e85864 100644 --- a/daemon/api_systems_test.go +++ b/daemon/api_systems_test.go @@ -24,11 +24,13 @@ import ( "encoding/json" "errors" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "os" "path" "path/filepath" + "strconv" "strings" "time" @@ -45,10 +47,12 @@ import ( "github.com/snapcore/snapd/gadget" "github.com/snapcore/snapd/gadget/quantity" "github.com/snapcore/snapd/overlord/assertstate/assertstatetest" + "github.com/snapcore/snapd/overlord/auth" "github.com/snapcore/snapd/overlord/devicestate" "github.com/snapcore/snapd/overlord/hookstate" "github.com/snapcore/snapd/overlord/install" "github.com/snapcore/snapd/overlord/restart" + "github.com/snapcore/snapd/overlord/snapstate" "github.com/snapcore/snapd/overlord/state" "github.com/snapcore/snapd/release" "github.com/snapcore/snapd/secboot" @@ -1228,3 +1232,693 @@ func (s *systemsSuite) TestSystemInstallActionError(c *check.C) { rspe := s.errorReq(c, req, nil) c.Check(rspe.Error(), check.Equals, `unsupported install step "unknown-install-step" (api)`) } + +var _ = check.Suite(&systemsCreateSuite{}) + +type systemsCreateSuite struct { + apiBaseSuite + + storeSigning *assertstest.StoreStack + dev1Signing *assertstest.SigningDB + dev1acct *asserts.Account + acct1Key *asserts.AccountKey + mockSeqFormingAssertionFn func(assertType *asserts.AssertionType, sequenceKey []string, sequence int, user *auth.UserState) (asserts.Assertion, error) + mockAssertionFn func(at *asserts.AssertionType, headers []string, user *auth.UserState) (asserts.Assertion, error) +} + +func (s *systemsCreateSuite) mockDevAssertion(c *check.C, t *asserts.AssertionType, extras map[string]interface{}) asserts.Assertion { + headers := map[string]interface{}{ + "type": t.Name, + "authority-id": s.dev1acct.AccountID(), + "account-id": s.dev1acct.AccountID(), + "series": "16", + "revision": "5", + "timestamp": "2030-11-06T09:16:26Z", + } + + for k, v := range extras { + headers[k] = v + } + + vs, err := s.dev1Signing.Sign(t, headers, nil, "") + c.Assert(err, check.IsNil) + return vs +} + +func (s *systemsCreateSuite) mockStoreAssertion(c *check.C, t *asserts.AssertionType, extras map[string]interface{}) asserts.Assertion { + headers := map[string]interface{}{ + "type": t.Name, + "authority-id": s.storeSigning.AuthorityID, + "account-id": s.dev1acct.AccountID(), + "series": "16", + "revision": "5", + "timestamp": "2030-11-06T09:16:26Z", + } + + for k, v := range extras { + headers[k] = v + } + + vs, err := s.storeSigning.Sign(t, headers, nil, "") + c.Assert(err, check.IsNil) + return vs +} + +func (s *systemsCreateSuite) SetUpTest(c *check.C) { + s.apiBaseSuite.SetUpTest(c) + d := s.daemon(c) + + s.expectRootAccess() + + restore := asserts.MockMaxSupportedFormat(asserts.ValidationSetType, 1) + s.AddCleanup(restore) + + s.mockSeqFormingAssertionFn = nil + s.mockAssertionFn = nil + + s.storeSigning = assertstest.NewStoreStack("can0nical", nil) + + st := d.Overlord().State() + st.Lock() + snapstate.ReplaceStore(st, s) + assertstatetest.AddMany(st, s.storeSigning.StoreAccountKey("")) + st.Unlock() + + s.dev1acct = assertstest.NewAccount(s.storeSigning, "developer1", nil, "") + c.Assert(s.storeSigning.Add(s.dev1acct), check.IsNil) + + dev1PrivKey, _ := assertstest.GenerateKey(752) + s.acct1Key = assertstest.NewAccountKey(s.storeSigning, s.dev1acct, nil, dev1PrivKey.PublicKey(), "") + + s.dev1Signing = assertstest.NewSigningDB(s.dev1acct.AccountID(), dev1PrivKey) + c.Assert(s.storeSigning.Add(s.acct1Key), check.IsNil) + + d.Overlord().Loop() + s.AddCleanup(func() { d.Overlord().Stop() }) +} + +func (s *systemsCreateSuite) Assertion(at *asserts.AssertionType, headers []string, user *auth.UserState) (asserts.Assertion, error) { + s.pokeStateLock() + return s.mockAssertionFn(at, headers, user) +} + +func (s *systemsCreateSuite) SeqFormingAssertion(assertType *asserts.AssertionType, sequenceKey []string, sequence int, user *auth.UserState) (asserts.Assertion, error) { + s.pokeStateLock() + return s.mockSeqFormingAssertionFn(assertType, sequenceKey, sequence, user) +} + +func (s *systemsCreateSuite) TestCreateSystemActionBadRequests(c *check.C) { + type test struct { + body map[string]interface{} + routeLabel string + result string + } + + tests := []test{ + { + body: map[string]interface{}{ + "action": "create", + }, + routeLabel: "label", + result: `label should not be provided in route when creating a system \(api\)`, + }, + { + body: map[string]interface{}{ + "action": "create", + "label": "", + }, + result: `label must be provided in request body for action "create" \(api\)`, + }, + { + body: map[string]interface{}{ + "action": "create", + "label": "label", + "validation-sets": []string{ + "not-a-validation-set", + }, + }, + result: `cannot parse validation sets: cannot parse validation set "not-a-validation-set": expected a single account/name \(api\)`, + }, + { + body: map[string]interface{}{ + "action": "create", + "label": "label", + "validation-sets": []string{ + "account/name", + }, + }, + result: `cannot fetch validation sets: validation-set assertion not found \(api\)`, + }, + } + + s.mockSeqFormingAssertionFn = func(assertType *asserts.AssertionType, sequenceKey []string, sequence int, user *auth.UserState) (asserts.Assertion, error) { + return nil, &asserts.NotFoundError{ + Type: assertType, + } + } + + for _, tc := range tests { + b, err := json.Marshal(tc.body) + c.Assert(err, check.IsNil) + + url := "/v2/systems" + if tc.routeLabel != "" { + url += "/" + tc.routeLabel + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(b)) + c.Assert(err, check.IsNil) + + rspe := s.errorReq(c, req, nil) + c.Check(rspe.Status, check.Equals, 400) + c.Check(rspe, check.ErrorMatches, tc.result, check.Commentf("%+v", tc)) + } +} + +func (s *systemsCreateSuite) TestCreateSystemActionValidationSet(c *check.C) { + const valSetSequence = 0 + s.testCreateSystemAction(c, valSetSequence) +} + +func (s *systemsCreateSuite) TestCreateSystemActionSpecificValdationSet(c *check.C) { + const valSetSequence = 1 + s.testCreateSystemAction(c, valSetSequence) +} + +func (s *systemsCreateSuite) testCreateSystemAction(c *check.C, requestedValSetSequence int) { + snaps := []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": snaptest.AssertedSnapID("pc-kernel"), + "revision": "10", + "presence": "required", + }, + map[string]interface{}{ + "name": "pc", + "id": snaptest.AssertedSnapID("pc"), + "revision": "10", + "presence": "required", + }, + map[string]interface{}{ + "name": "core20", + "id": snaptest.AssertedSnapID("core20"), + "revision": "10", + "presence": "required", + }, + } + + accountID := s.dev1acct.AccountID() + + const validationSet = "validation-set-1" + + vsetAssert := s.mockDevAssertion(c, asserts.ValidationSetType, map[string]interface{}{ + "name": validationSet, + "sequence": "1", + "snaps": snaps, + }) + + s.mockAssertionFn = func(at *asserts.AssertionType, key []string, user *auth.UserState) (asserts.Assertion, error) { + headers, err := asserts.HeadersFromPrimaryKey(at, key) + if err != nil { + return nil, err + } + + return s.storeSigning.Find(at, headers) + } + + s.mockSeqFormingAssertionFn = func(assertType *asserts.AssertionType, sequenceKey []string, sequence int, user *auth.UserState) (asserts.Assertion, error) { + if assertType != asserts.ValidationSetType { + return nil, &asserts.NotFoundError{ + Type: assertType, + } + } + + c.Check(sequence, check.Equals, requestedValSetSequence) + + return vsetAssert, nil + } + + const ( + markDefault = true + testSystem = true + expectedLabel = "1234" + ) + + daemon.MockDevicestateCreateRecoverySystem(func(st *state.State, label string, opts devicestate.CreateRecoverySystemOptions) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + c.Check(markDefault, check.Equals, opts.MarkDefault) + c.Check(testSystem, check.Equals, opts.TestSystem) + + c.Check(opts.ValidationSets, check.HasLen, 1) + + for _, vs := range opts.ValidationSets { + c.Check(vs.AccountID(), check.Equals, accountID) + } + + return st.NewChange("change", "..."), nil + }) + + valSetString := accountID + "/" + validationSet + if requestedValSetSequence > 0 { + valSetString += "=" + strconv.Itoa(requestedValSetSequence) + } + + body := map[string]interface{}{ + "action": "create", + "label": expectedLabel, + "validation-sets": []string{valSetString}, + "mark-default": markDefault, + "test-system": testSystem, + } + + b, err := json.Marshal(body) + c.Assert(err, check.IsNil) + + req, err := http.NewRequest("POST", "/v2/systems", bytes.NewBuffer(b)) + c.Assert(err, check.IsNil) + + res := s.asyncReq(c, req, nil) + + st := s.d.Overlord().State() + st.Lock() + defer st.Unlock() + + c.Check(st.Change(res.Change), check.NotNil) +} + +func createFormData(c *check.C, fields map[string][]string, snaps map[string]string) (bytes.Buffer, string) { + var b bytes.Buffer + w := multipart.NewWriter(&b) + + for k, vs := range fields { + for _, v := range vs { + err := w.WriteField(k, v) + c.Assert(err, check.IsNil) + } + } + + for name, content := range snaps { + part, err := w.CreateFormFile("snap", name) + c.Assert(err, check.IsNil) + + _, err = part.Write([]byte(content)) + c.Assert(err, check.IsNil) + } + + err := w.Close() + c.Assert(err, check.IsNil) + + return b, w.Boundary() +} + +func (s *systemsCreateSuite) TestRemoveSystemAction(c *check.C) { + const expectedLabel = "1234" + + daemon.MockDevicestateRemoveRecoverySystem(func(st *state.State, label string) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + + return st.NewChange("change", "..."), nil + }) + + body := map[string]interface{}{ + "action": "remove", + } + + b, err := json.Marshal(body) + c.Assert(err, check.IsNil) + + req, err := http.NewRequest("POST", "/v2/systems/"+expectedLabel, bytes.NewBuffer(b)) + c.Assert(err, check.IsNil) + + res := s.asyncReq(c, req, nil) + + st := s.d.Overlord().State() + st.Lock() + defer st.Unlock() + + c.Check(st.Change(res.Change), check.NotNil) +} + +func (s *systemsCreateSuite) TestRemoveSystemActionNotFound(c *check.C) { + const expectedLabel = "1234" + + daemon.MockDevicestateRemoveRecoverySystem(func(st *state.State, label string) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + return nil, devicestate.ErrNoRecoverySystem + }) + + body := map[string]interface{}{ + "action": "remove", + } + + b, err := json.Marshal(body) + c.Assert(err, check.IsNil) + + req, err := http.NewRequest("POST", "/v2/systems/"+expectedLabel, bytes.NewBuffer(b)) + c.Assert(err, check.IsNil) + + res := s.errorReq(c, req, nil) + c.Check(res.Status, check.Equals, 404) + c.Check(res.Message, check.Equals, "recovery system does not exist") +} + +func (s *systemsCreateSuite) TestCreateSystemActionOfflineBadRequests(c *check.C) { + type test struct { + fields map[string][]string + result string + } + + tests := []test{ + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1", "2"}, + }, + result: `expected exactly one "label" value in form \(api\)`, + }, + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1"}, + "test-system": {"false", "true"}, + }, + result: `expected at most one "test-system" value in form \(api\)`, + }, + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1"}, + "mark-default": {"false", "true"}, + }, + result: `expected at most one "mark-default" value in form \(api\)`, + }, + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1"}, + "validation-sets": {"id/set-1", "id/set-2"}, + }, + result: `expected at most one "validation-sets" value in form \(api\)`, + }, + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1"}, + "test-system": {"not-valid"}, + }, + result: `cannot parse "test-system" value as boolean: not-valid \(api\)`, + }, + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1"}, + "mark-default": {"not-valid"}, + }, + result: `cannot parse "mark-default" value as boolean: not-valid \(api\)`, + }, + { + fields: map[string][]string{ + "action": {"create"}, + "label": {"1"}, + "validation-sets": {"invalid-set-name"}, + }, + result: `cannot parse validation sets: cannot parse validation set "invalid-set-name": expected a single account/name \(api\)`, + }, + } + + snaps := map[string]string{ + "snap-1": "snap-1 contents", + "snap-2": "snap-2 contents", + } + + for _, tc := range tests { + form, boundary := createFormData(c, tc.fields, snaps) + + req, err := http.NewRequest("POST", "/v2/systems", &form) + c.Assert(err, check.IsNil) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Content-Length", strconv.Itoa(form.Len())) + + rspe := s.errorReq(c, req, nil) + c.Check(rspe.Status, check.Equals, 400) + c.Check(rspe, check.ErrorMatches, tc.result, check.Commentf("%+v", tc)) + + // make sure that form files we uploaded get removed on failure + files, err := filepath.Glob(filepath.Join(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix+"*")) + c.Assert(err, check.IsNil) + c.Check(files, check.HasLen, 0) + } +} + +func (s *systemsCreateSuite) TestCreateSystemActionOffline(c *check.C) { + snaps := []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": snaptest.AssertedSnapID("pc-kernel"), + "revision": "10", + "presence": "required", + }, + map[string]interface{}{ + "name": "pc", + "id": snaptest.AssertedSnapID("pc"), + "revision": "10", + "presence": "required", + }, + map[string]interface{}{ + "name": "core20", + "id": snaptest.AssertedSnapID("core20"), + "revision": "10", + "presence": "required", + }, + } + + accountID := s.dev1acct.AccountID() + + const ( + validationSet = "validation-set-1" + expectedLabel = "1234" + ) + + vsetAssert := s.mockDevAssertion(c, asserts.ValidationSetType, map[string]interface{}{ + "name": validationSet, + "sequence": "1", + "snaps": snaps, + }) + + assertions := []string{ + string(asserts.Encode(vsetAssert)), + string(asserts.Encode(s.acct1Key)), + string(asserts.Encode(s.dev1acct)), + } + + snapFormData := make(map[string]string) + for _, name := range []string{"pc-kernel", "pc", "core20"} { + f := snaptest.MakeTestSnapWithFiles(c, fmt.Sprintf("name: %s\nversion: 1", name), nil) + digest, size, err := asserts.SnapFileSHA3_384(f) + c.Assert(err, check.IsNil) + + rev := s.mockStoreAssertion(c, asserts.SnapRevisionType, map[string]interface{}{ + "snap-id": snaptest.AssertedSnapID(name), + "snap-sha3-384": digest, + "developer-id": s.dev1acct.AccountID(), + "snap-size": strconv.Itoa(int(size)), + "snap-revision": "10", + }) + + // this is required right now. should it be? + decl := s.mockStoreAssertion(c, asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": snaptest.AssertedSnapID(name), + "snap-name": name, + "publisher-id": s.dev1acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + }) + + assertions = append(assertions, string(asserts.Encode(rev)), string(asserts.Encode(decl))) + + content, err := os.ReadFile(f) + c.Assert(err, check.IsNil) + + snapFormData[name] = string(content) + } + + valSetString := accountID + "/" + validationSet + fields := map[string][]string{ + "action": {"create"}, + "assertion": assertions, + "label": {expectedLabel}, + "validation-sets": {valSetString}, + } + + form, boundary := createFormData(c, fields, snapFormData) + + daemon.MockDevicestateCreateRecoverySystem(func(st *state.State, label string, opts devicestate.CreateRecoverySystemOptions) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + c.Check(opts.ValidationSets, check.HasLen, 1) + c.Check(opts.ValidationSets[0].Body(), check.DeepEquals, vsetAssert.Body()) + + c.Check(opts.LocalSnaps, check.HasLen, 3) + + for _, vs := range opts.ValidationSets { + c.Check(vs.AccountID(), check.Equals, accountID) + } + + return st.NewChange("change", "..."), nil + }) + + req, err := http.NewRequest("POST", "/v2/systems", &form) + c.Assert(err, check.IsNil) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Content-Length", strconv.Itoa(form.Len())) + + res := s.asyncReq(c, req, nil) + + st := s.d.Overlord().State() + st.Lock() + defer st.Unlock() + + c.Check(st.Change(res.Change), check.NotNil) +} + +func (s *systemsCreateSuite) TestCreateSystemActionOfflinePreinstalledJSON(c *check.C) { + const ( + expectedLabel = "1234" + ) + + daemon.MockDevicestateCreateRecoverySystem(func(st *state.State, label string, opts devicestate.CreateRecoverySystemOptions) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + c.Check(opts.ValidationSets, check.HasLen, 0) + c.Check(opts.LocalSnaps, check.HasLen, 0) + c.Check(opts.Offline, check.Equals, true) + + return st.NewChange("change", "..."), nil + }) + + body := map[string]interface{}{ + "action": "create", + "label": expectedLabel, + "offline": true, + } + + b, err := json.Marshal(body) + c.Assert(err, check.IsNil) + + req, err := http.NewRequest("POST", "/v2/systems", bytes.NewBuffer(b)) + c.Assert(err, check.IsNil) + + res := s.asyncReq(c, req, nil) + + st := s.d.Overlord().State() + st.Lock() + defer st.Unlock() + + c.Check(st.Change(res.Change), check.NotNil) +} + +func (s *systemsCreateSuite) TestCreateSystemActionOfflinePreinstalledForm(c *check.C) { + const ( + expectedLabel = "1234" + ) + + fields := map[string][]string{ + "action": {"create"}, + "label": {expectedLabel}, + } + + form, boundary := createFormData(c, fields, nil) + + daemon.MockDevicestateCreateRecoverySystem(func(st *state.State, label string, opts devicestate.CreateRecoverySystemOptions) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + c.Check(opts.ValidationSets, check.HasLen, 0) + c.Check(opts.LocalSnaps, check.HasLen, 0) + c.Check(opts.Offline, check.Equals, true) + + return st.NewChange("change", "..."), nil + }) + + req, err := http.NewRequest("POST", "/v2/systems", &form) + c.Assert(err, check.IsNil) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Content-Length", strconv.Itoa(form.Len())) + + res := s.asyncReq(c, req, nil) + + st := s.d.Overlord().State() + st.Lock() + defer st.Unlock() + + c.Check(st.Change(res.Change), check.NotNil) +} + +func (s *systemsCreateSuite) TestCreateSystemActionOfflineJustValidationSets(c *check.C) { + accountID := s.dev1acct.AccountID() + + const ( + validationSet = "validation-set-1" + expectedLabel = "1234" + ) + + vsetAssert := s.mockDevAssertion(c, asserts.ValidationSetType, map[string]interface{}{ + "name": validationSet, + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": snaptest.AssertedSnapID("pc-kernel"), + "revision": "10", + "presence": "required", + }, + map[string]interface{}{ + "name": "pc", + "id": snaptest.AssertedSnapID("pc"), + "revision": "10", + "presence": "required", + }, + map[string]interface{}{ + "name": "core20", + "id": snaptest.AssertedSnapID("core20"), + "revision": "10", + "presence": "required", + }, + }, + }) + + assertions := []string{ + string(asserts.Encode(vsetAssert)), + string(asserts.Encode(s.acct1Key)), + string(asserts.Encode(s.dev1acct)), + } + + valSetString := accountID + "/" + validationSet + fields := map[string][]string{ + "action": {"create"}, + "assertion": assertions, + "label": {expectedLabel}, + "validation-sets": {valSetString}, + } + + form, boundary := createFormData(c, fields, nil) + + daemon.MockDevicestateCreateRecoverySystem(func(st *state.State, label string, opts devicestate.CreateRecoverySystemOptions) (*state.Change, error) { + c.Check(expectedLabel, check.Equals, label) + c.Check(opts.ValidationSets, check.HasLen, 1) + c.Check(opts.ValidationSets[0].Body(), check.DeepEquals, vsetAssert.Body()) + c.Check(opts.LocalSnaps, check.HasLen, 0) + c.Check(opts.Offline, check.Equals, true) + + return st.NewChange("change", "..."), nil + }) + + req, err := http.NewRequest("POST", "/v2/systems", &form) + c.Assert(err, check.IsNil) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Content-Length", strconv.Itoa(form.Len())) + + res := s.asyncReq(c, req, nil) + + st := s.d.Overlord().State() + st.Lock() + defer st.Unlock() + + c.Check(st.Change(res.Change), check.NotNil) +} diff --git a/daemon/export_api_systems_test.go b/daemon/export_api_systems_test.go index a67a735e869..d8fca6688f0 100644 --- a/daemon/export_api_systems_test.go +++ b/daemon/export_api_systems_test.go @@ -56,3 +56,15 @@ func MockDevicestateInstallSetupStorageEncryption(f func(*state.State, string, m devicestateInstallSetupStorageEncryption = f return restore } + +func MockDevicestateCreateRecoverySystem(f func(*state.State, string, devicestate.CreateRecoverySystemOptions) (*state.Change, error)) (restore func()) { + restore = testutil.Backup(&devicestateCreateRecoverySystem) + devicestateCreateRecoverySystem = f + return restore +} + +func MockDevicestateRemoveRecoverySystem(f func(*state.State, string) (*state.Change, error)) (restore func()) { + restore = testutil.Backup(&devicestateRemoveRecoverySystem) + devicestateRemoveRecoverySystem = f + return restore +} diff --git a/overlord/assertstate/assertstate.go b/overlord/assertstate/assertstate.go index 78f111757ef..e90756b866e 100644 --- a/overlord/assertstate/assertstate.go +++ b/overlord/assertstate/assertstate.go @@ -1130,8 +1130,8 @@ func TemporaryDB(st *state.State) *asserts.Database { return db.WithStackedBackstore(asserts.NewMemoryBackstore()) } -// ValidationSetsModelOptions contains options for ValidationSetsFromModel. -type ValidationSetsModelOptions struct { +// FetchValidationSetsOptions contains options for FetchValidationSets. +type FetchValidationSetsOptions struct { // Offline should be set to true if the store should not be accessed. Any // assertions will be retrieved from the existing assertions database. If // the assertions are not present in the database, an error will be @@ -1139,9 +1139,10 @@ type ValidationSetsModelOptions struct { Offline bool } -// ValidationSetsFromModel takes in a model and creates a -// snapasserts.ValidationSets from any validation sets that the model includes. -func ValidationSetsFromModel(st *state.State, model *asserts.Model, opts ValidationSetsModelOptions, deviceCtx snapstate.DeviceContext) (*snapasserts.ValidationSets, error) { +// FetchValidationSets fetches the given validation set assertions from either +// the store or the existing assertions database. The validation sets are added +// to a snapasserts.ValidationSets, checked for any conflicts, and returned. +func FetchValidationSets(st *state.State, toFetch []*asserts.AtSequence, opts FetchValidationSetsOptions, deviceCtx snapstate.DeviceContext) (*snapasserts.ValidationSets, error) { var sets []*asserts.ValidationSet save := func(a asserts.Assertion) error { if vs, ok := a.(*asserts.ValidationSet); ok { @@ -1187,8 +1188,8 @@ func ValidationSetsFromModel(st *state.State, model *asserts.Model, opts Validat fetcher := asserts.NewSequenceFormingFetcher(db, retrieve, retrieveSeq, save) - for _, vs := range model.ValidationSets() { - if err := fetcher.FetchSequence(vs.AtSequence()); err != nil { + for _, vs := range toFetch { + if err := fetcher.FetchSequence(vs); err != nil { return nil, err } } @@ -1205,6 +1206,17 @@ func ValidationSetsFromModel(st *state.State, model *asserts.Model, opts Validat return vSets, nil } +// ValidationSetsFromModel takes in a model and creates a +// snapasserts.ValidationSets from any validation sets that the model includes. +func ValidationSetsFromModel(st *state.State, model *asserts.Model, opts FetchValidationSetsOptions, deviceCtx snapstate.DeviceContext) (*snapasserts.ValidationSets, error) { + toFetch := make([]*asserts.AtSequence, 0, len(model.ValidationSets())) + for _, vs := range model.ValidationSets() { + toFetch = append(toFetch, vs.AtSequence()) + } + + return FetchValidationSets(st, toFetch, opts, deviceCtx) +} + func resolveValidationSetAssertion(seq *asserts.AtSequence, db asserts.RODatabase) (asserts.Assertion, error) { if seq.Sequence <= 0 { hdrs, err := asserts.HeadersFromSequenceKey(seq.Type, seq.SequenceKey) diff --git a/overlord/assertstate/assertstate_test.go b/overlord/assertstate/assertstate_test.go index 1aede5e49e1..8670e099bfe 100644 --- a/overlord/assertstate/assertstate_test.go +++ b/overlord/assertstate/assertstate_test.go @@ -4927,46 +4927,38 @@ func (f *fakeAssertionStore) SeqFormingAssertion(assertType *asserts.AssertionTy return f.seqFormingAssertion(assertType, sequenceKey, sequence, user) } +func (s *assertMgrSuite) TestFetchValidationSetsOnline(c *C) { + s.testFetchValidationSets(c, testFetchValidationSetsOpts{}) +} + +func (s *assertMgrSuite) TestFetchValidationSetsOffline(c *C) { + s.testFetchValidationSets(c, testFetchValidationSetsOpts{ + Offline: true, + }) +} + func (s *assertMgrSuite) TestValidationSetsFromModelOnline(c *C) { - const offline = false - s.testValidationSetsFromModel(c, offline) + s.testFetchValidationSets(c, testFetchValidationSetsOpts{ + FromModel: true, + }) } func (s *assertMgrSuite) TestValidationSetsFromModelOffline(c *C) { - const offline = true - s.testValidationSetsFromModel(c, offline) + s.testFetchValidationSets(c, testFetchValidationSetsOpts{ + Offline: true, + FromModel: true, + }) +} + +type testFetchValidationSetsOpts struct { + Offline bool + FromModel bool } -func (s *assertMgrSuite) testValidationSetsFromModel(c *C, offline bool) { +func (s *assertMgrSuite) testFetchValidationSets(c *C, opts testFetchValidationSetsOpts) { s.state.Lock() defer s.state.Unlock() - model := assertstest.FakeAssertion(map[string]interface{}{ - "type": "model", - "authority-id": "my-brand", - "series": "16", - "brand-id": "my-brand", - "model": "my-model", - "architecture": "amd64", - "gadget": "gadget", - "kernel": "krnl", - "validation-sets": []interface{}{ - map[string]interface{}{ - "account-id": s.dev1Acct.AccountID(), - "name": "foo", - "mode": "enforce", - }, - map[string]interface{}{ - "account-id": s.dev1Acct.AccountID(), - "name": "bar", - "sequence": "2", - "mode": "enforce", - }, - }, - }).(*asserts.Model) - - s.setModel(model) - snapsInFoo := []interface{}{ map[string]interface{}{ "id": snaptest.AssertedSnapID("some-snap"), @@ -4988,7 +4980,7 @@ func (s *assertMgrSuite) testValidationSetsFromModel(c *C, offline bool) { barVset := s.validationSetAssertForSnaps(c, "bar", "2", "1", snapsInBar) var store snapstate.StoreService - if offline { + if opts.Offline { c.Assert(assertstate.Add(s.state, s.storeSigning.StoreAccountKey("")), IsNil) c.Assert(assertstate.Add(s.state, s.dev1Acct), IsNil) c.Assert(assertstate.Add(s.state, s.dev1AcctKey), IsNil) @@ -5035,10 +5027,64 @@ func (s *assertMgrSuite) testValidationSetsFromModel(c *C, offline bool) { CtxStore: store, } - sets, err := assertstate.ValidationSetsFromModel(s.state, model, assertstate.ValidationSetsModelOptions{ - Offline: offline, - }, deviceCtx) - c.Assert(err, IsNil) + var sets *snapasserts.ValidationSets + + if opts.FromModel { + model := assertstest.FakeAssertion(map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "series": "16", + "brand-id": "my-brand", + "model": "my-model", + "architecture": "amd64", + "gadget": "gadget", + "kernel": "krnl", + "validation-sets": []interface{}{ + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "name": "foo", + "mode": "enforce", + }, + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "name": "bar", + "sequence": "2", + "mode": "enforce", + }, + }, + }).(*asserts.Model) + + s.setModel(model) + + model.ValidationSets() + + var err error + sets, err = assertstate.ValidationSetsFromModel(s.state, model, assertstate.FetchValidationSetsOptions{ + Offline: opts.Offline, + }, deviceCtx) + c.Assert(err, IsNil) + } else { + toFetch := []*asserts.AtSequence{ + { + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, s.dev1Acct.AccountID(), "foo"}, + Revision: asserts.RevisionNotKnown, + }, + { + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, s.dev1Acct.AccountID(), "bar"}, + Sequence: 2, + Pinned: true, + Revision: asserts.RevisionNotKnown, + }, + } + + var err error + sets, err = assertstate.FetchValidationSets(s.state, toFetch, assertstate.FetchValidationSetsOptions{ + Offline: opts.Offline, + }, deviceCtx) + c.Assert(err, IsNil) + } c.Check(sets.RequiredSnaps(), testutil.DeepUnsortedMatches, []string{"some-snap", "some-other-snap"}) c.Check(sets.Keys(), testutil.DeepUnsortedMatches, []snapasserts.ValidationSetKey{ @@ -5103,7 +5149,7 @@ func (s *assertMgrSuite) TestValidationSetsFromModelConflict(c *C) { c.Assert(assertstate.Add(s.state, barVset), IsNil) c.Assert(assertstate.Add(s.state, fooVset), IsNil) - _, err := assertstate.ValidationSetsFromModel(s.state, model, assertstate.ValidationSetsModelOptions{ + _, err := assertstate.ValidationSetsFromModel(s.state, model, assertstate.FetchValidationSetsOptions{ Offline: true, }, s.trivialDeviceCtx) c.Check(err, testutil.ErrorIs, &snapasserts.ValidationSetsConflictError{}) diff --git a/overlord/devicestate/devicestate.go b/overlord/devicestate/devicestate.go index 7da0d79d3a3..3905e229347 100644 --- a/overlord/devicestate/devicestate.go +++ b/overlord/devicestate/devicestate.go @@ -1217,7 +1217,7 @@ func checkRequiredGadgetMatchesModelBase(model *asserts.Model, tracker *snap.Sel } func verifyModelValidationSets(st *state.State, newModel *asserts.Model, offline bool, deviceCtx snapstate.DeviceContext) (*snapasserts.ValidationSets, error) { - vSets, err := assertstate.ValidationSetsFromModel(st, newModel, assertstate.ValidationSetsModelOptions{ + vSets, err := assertstate.ValidationSetsFromModel(st, newModel, assertstate.FetchValidationSetsOptions{ Offline: offline, }, deviceCtx) if err != nil { @@ -1634,6 +1634,10 @@ type CreateRecoverySystemOptions struct { // MarkDefault is set to true if the new recovery system should be marked as // the default recovery system. MarkDefault bool + + // Offline is true if the recovery system should be created without reaching + // out to the store. Offline must be set to true if LocalSnaps is provided. + Offline bool } var ErrNoRecoverySystem = errors.New("recovery system does not exist") @@ -1674,6 +1678,10 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst return nil, err } + if !opts.Offline && len(opts.LocalSnaps) > 0 { + return nil, errors.New("locally provided snaps cannot be provided when creating a recovery system online") + } + var seeded bool err = st.Get("seeded", &seeded) if err != nil && !errors.Is(err, state.ErrNoState) { @@ -1719,7 +1727,6 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst } tracker := snap.NewSelfContainedSetPrereqTracker() - offline := len(opts.LocalSnaps) > 0 var downloadTSS []*state.TaskSet for _, sn := range model.AllSnaps() { @@ -1752,7 +1759,7 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst } } - if offline { + if opts.Offline { info, err := offlineSnapInfo(sn, rev, opts) if err != nil { return nil, err diff --git a/overlord/devicestate/devicestate_systems_test.go b/overlord/devicestate/devicestate_systems_test.go index bb97a9dad6c..f6cf4812e05 100644 --- a/overlord/devicestate/devicestate_systems_test.go +++ b/overlord/devicestate/devicestate_systems_test.go @@ -3619,6 +3619,137 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid } } +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemOnlineWithLocalError(c *C) { + devicestate.SetBootOkRan(s.mgr, true) + + s.state.Lock() + defer s.state.Unlock() + + _, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ + TestSystem: true, + LocalSnaps: []devicestate.LocalSnap{{SideInfo: &snap.SideInfo{}, Path: "/some/path"}}, + }) + c.Assert(err, ErrorMatches, "locally provided snaps cannot be provided when creating a recovery system online") +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemOfflinePreinstalled(c *C) { + devicestate.SetBootOkRan(s.mgr, true) + + s.state.Lock() + defer s.state.Unlock() + + devicestate.MockSnapstateDownload(func( + _ context.Context, _ *state.State, name string, _ string, opts *snapstate.RevisionOptions, _ int, _ snapstate.Flags, _ snapstate.DeviceContext) (*state.TaskSet, *snap.Info, error, + ) { + c.Errorf("snapstate.Download called unexpectedly") + return nil, nil, nil + }) + + s.state.Set("refresh-privacy-key", "some-privacy-key") + s.mockStandardSnapsModeenvAndBootloaderState(c) + + chg, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ + TestSystem: true, + Offline: true, + }) + c.Assert(err, IsNil) + c.Assert(chg, NotNil) + tsks := chg.Tasks() + // create system + finalize system + c.Check(tsks, HasLen, 2) + tskCreate := tsks[0] + tskFinalize := tsks[1] + c.Assert(tskCreate.Summary(), Matches, `Create recovery system with label "1234"`) + c.Check(tskFinalize.Summary(), Matches, `Finalize recovery system with label "1234"`) + + s.state.Unlock() + s.settle(c) + s.state.Lock() + + c.Assert(chg.Err(), IsNil) + c.Assert(tskCreate.Status(), Equals, state.WaitStatus) + c.Assert(tskFinalize.Status(), Equals, state.DoStatus) + + // a reboot is expected + c.Check(s.restartRequests, DeepEquals, []restart.RestartType{restart.RestartSystemNow}) + + validateCore20Seed(c, "1234", s.model, s.storeSigning.Trusted) + m, err := s.bootloader.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]string{ + "try_recovery_system": "1234", + "recovery_system_status": "try", + }) + modeenvAfterCreate, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvAfterCreate, testutil.JsonEquals, boot.Modeenv{ + Mode: "run", + Base: "core20_3.snap", + CurrentKernels: []string{"pc-kernel_2.snap"}, + CurrentRecoverySystems: []string{"othersystem", "1234"}, + GoodRecoverySystems: []string{"othersystem"}, + + Model: s.model.Model(), + BrandID: s.model.BrandID(), + Grade: string(s.model.Grade()), + ModelSignKeyID: s.model.SignKeyID(), + }) + + // verify that new files are tracked correctly + expectedFilesLog := &bytes.Buffer{} + for _, fname := range []string{"snapd_4.snap", "pc-kernel_2.snap", "core20_3.snap", "pc_1.snap"} { + fmt.Fprintln(expectedFilesLog, filepath.Join(boot.InitramfsUbuntuSeedDir, "snaps", fname)) + } + + c.Check(filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "1234", "snapd-new-file-log"), + testutil.FileEquals, expectedFilesLog.String()) + + // these things happen on snapd startup + restart.MockPending(s.state, restart.RestartUnset) + s.state.Set("tried-systems", []string{"1234"}) + s.bootloader.SetBootVars(map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) + s.bootloader.SetBootVarsCalls = 0 + + s.state.Unlock() + s.settle(c) + s.state.Lock() + + // simulate a restart and run change to completion + s.mockRestartAndSettle(c, s.state, chg) + + c.Assert(chg.Err(), IsNil) + c.Check(chg.IsReady(), Equals, true) + c.Assert(tskCreate.Status(), Equals, state.DoneStatus) + c.Assert(tskFinalize.Status(), Equals, state.DoneStatus) + + var triedSystemsAfterFinalize []string + err = s.state.Get("tried-systems", &triedSystemsAfterFinalize) + c.Assert(err, testutil.ErrorIs, state.ErrNoState) + + modeenvAfterFinalize, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvAfterFinalize, testutil.JsonEquals, boot.Modeenv{ + Mode: "run", + Base: "core20_3.snap", + CurrentKernels: []string{"pc-kernel_2.snap"}, + CurrentRecoverySystems: []string{"othersystem", "1234"}, + GoodRecoverySystems: []string{"othersystem", "1234"}, + + Model: s.model.Model(), + BrandID: s.model.BrandID(), + Grade: string(s.model.Grade()), + ModelSignKeyID: s.model.SignKeyID(), + }) + + // expect 1 more call to bootloader.SetBootVars, since we're marking this + // system as seeded + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + c.Check(filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "1234", "snapd-new-file-log"), testutil.FileAbsent) +} + func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsOffline(c *C) { devicestate.SetBootOkRan(s.mgr, true) @@ -3714,6 +3845,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid chg, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ ValidationSets: []*asserts.ValidationSet{vsetAssert.(*asserts.ValidationSet)}, LocalSnaps: localSnaps, + Offline: true, TestSystem: true, }) c.Assert(err, IsNil) @@ -3899,6 +4031,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid _, err = devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ ValidationSets: []*asserts.ValidationSet{vsetAssert.(*asserts.ValidationSet)}, LocalSnaps: localSnaps, + Offline: true, }) c.Assert(err, ErrorMatches, `snap "pc" does not match revision required by validation sets: 100 != 10`) } @@ -3967,6 +4100,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemMissi _, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ LocalSnaps: localSnaps, + Offline: true, }) c.Assert(err, ErrorMatches, `cannot create recovery system from model with snap that has no snap id: "pc"`) } @@ -4009,6 +4143,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemMissi _, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ LocalSnaps: localSnaps, + Offline: true, }) c.Assert(err, ErrorMatches, `cannot create recovery system from provided snap that has no snap id: "pc"`) } @@ -4098,6 +4233,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid _, err = devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ ValidationSets: []*asserts.ValidationSet{vsetAssert.(*asserts.ValidationSet)}, LocalSnaps: localSnaps, + Offline: true, }) c.Assert(err, ErrorMatches, `missing snap from local snaps provided for offline creation of recovery system: "pc", rev 10`) } @@ -4325,6 +4461,7 @@ plugs: _, err = devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ ValidationSets: []*asserts.ValidationSet{vset}, LocalSnaps: localSnaps, + Offline: true, }) msg := `cannot create recovery system from model that is not self-contained: diff --git a/overlord/snapstate/snapstate_test.go b/overlord/snapstate/snapstate_test.go index 33c2b535b1c..0810d5656e2 100644 --- a/overlord/snapstate/snapstate_test.go +++ b/overlord/snapstate/snapstate_test.go @@ -3747,8 +3747,8 @@ func (s *snapmgrTestSuite) TestEsnureCleansOldSideloads(c *C) { c.Assert(filenames(), HasLen, 0) s0 := filepath.Join(dirs.SnapBlobDir, "some.snap") - s1 := filepath.Join(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix+"-12345") - s2 := filepath.Join(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix+"-67890") + s1 := filepath.Join(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix+"-12345.snap") + s2 := filepath.Join(dirs.SnapBlobDir, dirs.LocalInstallBlobTempPrefix+"-67890.snap") c.Assert(os.WriteFile(s0, nil, 0600), IsNil) c.Assert(os.WriteFile(s1, nil, 0600), IsNil) diff --git a/spread.yaml b/spread.yaml index 76fb08790ef..f6d7d22934d 100644 --- a/spread.yaml +++ b/spread.yaml @@ -104,6 +104,7 @@ environment: NESTED_REPACK_KERNEL_SNAP: '$(HOST: echo "${NESTED_REPACK_KERNEL_SNAP:-true}")' NESTED_REPACK_GADGET_SNAP: '$(HOST: echo "${NESTED_REPACK_GADGET_SNAP:-true}")' NESTED_REPACK_BASE_SNAP: '$(HOST: echo "${NESTED_REPACK_BASE_SNAP:-true}")' + NESTED_FORCE_MS_KEYS: '$(HOST: echo "${NESTED_FORCE_MS_KEYS:-false}")' backends: google: diff --git a/tests/lib/assertions/test-snapd-recovery-system-pc-20.json b/tests/lib/assertions/test-snapd-recovery-system-pc-20.json new file mode 100644 index 00000000000..904165dde75 --- /dev/null +++ b/tests/lib/assertions/test-snapd-recovery-system-pc-20.json @@ -0,0 +1,53 @@ +{ + "type": "model", + "authority-id": "test-snapd", + "series": "16", + "brand-id": "test-snapd", + "model": "my-model", + "revision": "1", + "architecture": "amd64", + "timestamp": "2024-01-04T15:49:41+00:00", + "grade": "dangerous", + "base": "core20", + "serial-authority": ["generic"], + "snaps": [ + { + "default-channel": "20/stable", + "id": "UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH", + "name": "pc", + "type": "gadget" + }, + { + "default-channel": "20/stable", + "id": "pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza", + "name": "pc-kernel", + "type": "kernel" + }, + { + "default-channel": "latest/stable", + "id": "DLqre5XGLbDqg9jPtiAhRRjDuPVa5X1q", + "name": "core20", + "type": "base" + }, + { + "default-channel": "latest/stable", + "id": "PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4", + "name": "snapd", + "type": "snapd" + }, + { + "default-channel": "latest/stable", + "id": "99T7MUlRhtI3U0QFgl5mXXESAiSwt776", + "name": "core", + "type": "core", + "modes": ["run", "ephemeral"] + }, + { + "default-channel": "latest/stable", + "id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "type": "app", + "modes": ["run", "ephemeral"] + } + ] +} diff --git a/tests/lib/assertions/test-snapd-recovery-system-pc-20.model b/tests/lib/assertions/test-snapd-recovery-system-pc-20.model new file mode 100644 index 00000000000..e5e3cfcc0dc --- /dev/null +++ b/tests/lib/assertions/test-snapd-recovery-system-pc-20.model @@ -0,0 +1,61 @@ +type: model +authority-id: test-snapd +revision: 1 +series: 16 +brand-id: test-snapd +model: my-model +architecture: amd64 +base: core20 +grade: dangerous +serial-authority: + - generic +snaps: + - + default-channel: 20/stable + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + name: pc + type: gadget + - + default-channel: 20/stable + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + name: pc-kernel + type: kernel + - + default-channel: latest/stable + id: DLqre5XGLbDqg9jPtiAhRRjDuPVa5X1q + name: core20 + type: base + - + default-channel: latest/stable + id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4 + name: snapd + type: snapd + - + default-channel: latest/stable + id: 99T7MUlRhtI3U0QFgl5mXXESAiSwt776 + modes: + - run + - ephemeral + name: core + type: core + - + default-channel: latest/stable + id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ + modes: + - run + - ephemeral + name: hello-world + type: app +timestamp: 2024-01-04T15:49:41+00:00 +sign-key-sha3-384: 7qWG-Uwck6Dji43a3Z8ZZrm7rAziZAch3xf76iFvqe4GaD0LI7U9lYPWMSJAsEgu + +AcLBcwQAAQoAHRYhBGESvKlz1RXG1IBOC0MdJKf2hr9ABQJlmHhdAAoJEEMdJKf2hr9ArGgP+wcb +of6dUliYw+yEP7FiBS8HN70/oV/SYRKhTGdsmX5xnbkWMIQ6y8eRmN2z2L3R7M6fVVzMRkZ4zvvf +rJtss8vAToxLmjZEKINEuLVZvZA574HzV49EUITrYs/X9pIX+fXbLEXS30vd5OH5/vZvxnmDAh6j +0WivCWwNVtk1v7ufrMXg2f//QO1XLKJO1DB+TGPqpeFjGB5cEyqLRZsf86N3Gxy+Lu+d1cNqLmEP +VSBfdTkA4ynl8U3GaD0/Bxc6YCZ0xY7Cvzoxx8ybqKOmaG9xPFDB3Ek/Ruyqtklx46wTVqG4RAfs +BGt6zGasPne5kqUA7KDyhG0lgKPVbmaTptYH9I9TvG3Plt5lTH/PUOaDh0xAUe8HjS5wshSBdtCS +4OmRvTmSOwff58H7z9eLXr3j2+p3hYSVuCCvjWQWWx/qbXCHC0IQKcO+JhOKcC2rspf1RfUhD+7R +dqhGtEYtUV1oWHMbXEqVAgDXB6FSMjjiCCGe9oRJB9Lq+BeNqVBbnfkRfbd4aua2BTea8cnq9DeI +Gb92A7I71UUokxiht0jqlQ9fxV8qmRGEGPYylE+Jxpk/WmlrQ6mDvfSPpAShidRhOQlfIT7H9fTb +q5QotnwJirGmfta0SbDFRmdh/daxse9//4rcBdZGcVw85lcfqae8rjhorwzC63lCiQUOZ3EK diff --git a/tests/lib/assertions/test-snapd-recovery-system-pc-22.json b/tests/lib/assertions/test-snapd-recovery-system-pc-22.json new file mode 100644 index 00000000000..ebe778a3cc8 --- /dev/null +++ b/tests/lib/assertions/test-snapd-recovery-system-pc-22.json @@ -0,0 +1,53 @@ +{ + "type": "model", + "authority-id": "test-snapd", + "series": "16", + "brand-id": "test-snapd", + "model": "my-model", + "revision": "1", + "architecture": "amd64", + "timestamp": "2024-01-04T15:49:41+00:00", + "grade": "dangerous", + "base": "core22", + "serial-authority": ["generic"], + "snaps": [ + { + "default-channel": "22/stable", + "id": "UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH", + "name": "pc", + "type": "gadget" + }, + { + "default-channel": "22/stable", + "id": "pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza", + "name": "pc-kernel", + "type": "kernel" + }, + { + "default-channel": "latest/stable", + "id": "amcUKQILKXHHTlmSa7NMdnXSx02dNeeT", + "name": "core22", + "type": "base" + }, + { + "default-channel": "latest/stable", + "id": "PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4", + "name": "snapd", + "type": "snapd" + }, + { + "default-channel": "latest/stable", + "id": "99T7MUlRhtI3U0QFgl5mXXESAiSwt776", + "name": "core", + "type": "core", + "modes": ["run", "ephemeral"] + }, + { + "default-channel": "latest/stable", + "id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "type": "app", + "modes": ["run", "ephemeral"] + } + ] +} diff --git a/tests/lib/assertions/test-snapd-recovery-system-pc-22.model b/tests/lib/assertions/test-snapd-recovery-system-pc-22.model new file mode 100644 index 00000000000..1cd2d21a646 --- /dev/null +++ b/tests/lib/assertions/test-snapd-recovery-system-pc-22.model @@ -0,0 +1,61 @@ +type: model +authority-id: test-snapd +revision: 1 +series: 16 +brand-id: test-snapd +model: my-model +architecture: amd64 +base: core22 +grade: dangerous +serial-authority: + - generic +snaps: + - + default-channel: 22/stable + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + name: pc + type: gadget + - + default-channel: 22/stable + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + name: pc-kernel + type: kernel + - + default-channel: latest/stable + id: amcUKQILKXHHTlmSa7NMdnXSx02dNeeT + name: core22 + type: base + - + default-channel: latest/stable + id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4 + name: snapd + type: snapd + - + default-channel: latest/stable + id: 99T7MUlRhtI3U0QFgl5mXXESAiSwt776 + modes: + - run + - ephemeral + name: core + type: core + - + default-channel: latest/stable + id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ + modes: + - run + - ephemeral + name: hello-world + type: app +timestamp: 2024-01-04T15:49:41+00:00 +sign-key-sha3-384: 7qWG-Uwck6Dji43a3Z8ZZrm7rAziZAch3xf76iFvqe4GaD0LI7U9lYPWMSJAsEgu + +AcLBcwQAAQoAHRYhBGESvKlz1RXG1IBOC0MdJKf2hr9ABQJl4gLjAAoJEEMdJKf2hr9ACKkP/12Z +uix52q9+WujCDSNlJthql1xBmN5/IccbUWOff4P2Zv3V3c0j5HMon5lnnfcPSu69Wv9L8JT0xdr+ +Ee5Blaqw9549dtwJ2JtMtCpJmNcKHXYleJQOIEVtK2zL5YvYDShUG1cx1gEIls7fiKAAbebtpKcv +uNbZZnxf3gGkIaqxSjJInv4t8Txd2BBlmMvIG0C7r/iWV93UYwPKm9FPRPWAFE4DauippViQkWGP +NbeZJ8D9/iWwEUuY21scCE+l7JcXpOjIhNFH34PnisfW050QNxCnfH6XUNmplfVGimWt9W7g+v7m +nSIWE/MoBCz8H0eaf9V8hroqy5vG+IqUg5Tb91FvLspc2lq1eRhei0DA/Ys47QLnVViOmHwgXGJY +pFBz52m6BEE67ByNN/U0DLeRT5AEl5CSZP4DFRfn+r/y3EHWQ10MXZ4zN7HQb7SMuxvRlx6FUl46 +TESmnS2U7QIToe9h31tKMTI1jNkgd22Y4h9aLT+FMNvUVrz49FN/5eLAkCy2oaUKnme8qv0VzTbp +W2Rv3cXgQqu1NhOK1QMTj+7kJUn4fi1/UAD8lo7K2G+FJdyznQKDcstLZnCsRr8+k9kp7yRjZzF6 +cgRHFrvtfsxUYfUFNN2nCZ7NWqVUdYgheoVc6suTUcQvuvx1lQAH2OvNJYu4DMI5cMffD0TJ diff --git a/tests/lib/assertions/test-snapd-recovery-system-pinned.assert b/tests/lib/assertions/test-snapd-recovery-system-pinned.assert new file mode 100644 index 00000000000..d66eb0949b6 --- /dev/null +++ b/tests/lib/assertions/test-snapd-recovery-system-pinned.assert @@ -0,0 +1,44 @@ +type: validation-set +authority-id: test-snapd +series: 16 +account-id: test-snapd +name: recovery-system-pinned +sequence: 1 +snaps: + - + id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ + name: hello-world + revision: 28 + - + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + name: pc + presence: required + - + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + name: pc-kernel + presence: required + - + id: DLqre5XGLbDqg9jPtiAhRRjDuPVa5X1q + name: core20 + presence: required + - + id: 99T7MUlRhtI3U0QFgl5mXXESAiSwt776 + name: core + presence: required + - + id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4 + name: snapd + presence: required +timestamp: 2024-01-04T21:09:22Z +sign-key-sha3-384: 7qWG-Uwck6Dji43a3Z8ZZrm7rAziZAch3xf76iFvqe4GaD0LI7U9lYPWMSJAsEgu + +AcLBcwQAAQoAHRYhBGESvKlz1RXG1IBOC0MdJKf2hr9ABQJllx6CAAoJEEMdJKf2hr9A3KQP/RQb +/apZ6AXmv0Tfb/QFiFD1Fc5/Pzf12ANWQT5Omoz+JmTCjFIEJxjWjt51S0zxxdIpvY5366c0baZW +UE09+6khrL5Tcy4xjbpbIh9wZEiRwAQI/rBC3kD8sjuj0ggkMayMnF2CmCKJXt8wb3Um94xkxJda +5DHvJbp+N4E1IDeRMZWis3AJd9TyaYOpnISrREx8fDzaq3cu0XTnXUihIY5zti+jYKpLJ7lss0dj +EOjmnQ1HDhRsvLdD6ISyeW/BuKT1/pzPJJ24bp1dnV3hXCN8iz99QyTQq3DezMVgmZfU3jlyMAFk +0YNlVOLHwnMbf99rQe1ogkmMjS9p7v8K9Dqp4nfJL98WCssff5rIcohlH0JzkUHMFHtvmjOGFjor +O6p+JbJGtb+5R2SW7ncnzA4TohwBsDwDRVyuT/Y7e+wxxJsfrDH2lf2/ku1VPewW7DW9CEPWiqlu +RLa09NcMoo/w2FsnbjNcOg2oiTRbOhgK7w1vG3jfX4namowd7GRflEzA0GDfWrtxqi6NtYBX4/Mp +l+UozC63rV5NE9k0Jy7+kMyVJcDF93VRxwFbsNubaYUMgSgxgpC2cnkfP405nRPH0UcqctUzB58R +VvylWkxgI1lmghZEIaCrnXwXXptDK7l0ApN5LpyokM6Nc1fg8px65Z/4M43GfrHBz7TcKvt2 diff --git a/tests/lib/nested.sh b/tests/lib/nested.sh index a64ac462fd1..23498beafa7 100755 --- a/tests/lib/nested.sh +++ b/tests/lib/nested.sh @@ -1165,7 +1165,7 @@ nested_start_core_vm_unit() { mv OVMF_VARS.snakeoil.fd /usr/share/OVMF/OVMF_VARS.snakeoil.fd fi # In this case the kernel.efi is unsigned and signed with snaleoil certs - if [ "$NESTED_BUILD_SNAPD_FROM_CURRENT" = "true" ]; then + if [ "$NESTED_FORCE_MS_KEYS" != "true" ] && [ "$NESTED_BUILD_SNAPD_FROM_CURRENT" = "true" ]; then OVMF_VARS="snakeoil" fi diff --git a/tests/nested/manual/recovery-system-offline/task.yaml b/tests/nested/manual/recovery-system-offline/task.yaml new file mode 100644 index 00000000000..7a1a38de336 --- /dev/null +++ b/tests/nested/manual/recovery-system-offline/task.yaml @@ -0,0 +1,110 @@ +summary: create and remove a recovery system using the offline API +details: | + This test creates a recovery system using the offline version of the recovery + system creation API. + +systems: [ubuntu-20.04-64, ubuntu-22.04-64] + +environment: + NESTED_CUSTOM_MODEL: $TESTSLIB/assertions/test-snapd-recovery-system-pc-{VERSION}.model + NESTED_ENABLE_TPM: true + NESTED_ENABLE_SECURE_BOOT: true + NESTED_BUILD_SNAPD_FROM_CURRENT: true + TEST_SYSTEM: false + USE_FORM_API: true + PRE_INSTALLED_SNAPS: false + + TEST_SYSTEM/untested: false + TEST_SYSTEM/tested: true + + PRE_INSTALLED_SNAPS/pre_installed_snaps: true + + PRE_INSTALLED_SNAPS/pre_installed_snaps_json: true + USE_FORM_API/pre_installed_snaps_json: false + +prepare: | + tests.nested build-image core + tests.nested create-vm core + +execute: | + function post_json_data() { + route=$1 + template=$2 + shift 2 + + # shellcheck disable=SC2059 + printf "${template}" "$@" | remote.exec "sudo test-snapd-curl.curl -X POST -H 'Content-Type: application/json' --unix-socket /run/snapd.socket -d @- http://localhost${route}" | jq . + } + + #shellcheck source=tests/lib/core-config.sh + . "$TESTSLIB"/core-config.sh + + wait_for_first_boot_change + + remote.exec sudo snap install --edge --devmode test-snapd-curl + + boot_id="$(tests.nested boot-id)" + + remote.push "${TESTSLIB}/assertions/test-snapd-recovery-system-pinned.assert" + remote.exec snap download hello-world --revision=28 --basename=hello-world + + if [ "${PRE_INSTALLED_SNAPS}" = 'true' ]; then + remote.exec "sudo snap refresh --revision=28 hello-world" + remote.exec "sudo snap ack test-snapd-recovery-system-pinned.assert" + + remote.exec "sudo snap set system store.access=offline" + remote.exec "sudo ip r d default" + + if [ "${USE_FORM_API}" = 'true' ]; then + response=$(remote.exec "sudo test-snapd-curl.curl -X POST --unix-socket /run/snapd.socket -F 'action=create' -F 'label=new-system' -F 'validation-sets=test-snapd/recovery-system-pinned' -F 'test-system=${TEST_SYSTEM}' -F 'mark-default=true' http://localhost/v2/systems") + else + response=$(post_json_data /v2/systems '{"action": "create", "label": "new-system", "validation-sets": ["test-snapd/recovery-system-pinned"], "mark-default": true, "offline": true, "test-system": %s}' "${TEST_SYSTEM}") + fi + else + remote.exec "sudo snap set system store.access=offline" + remote.exec "sudo ip r d default" + + response=$(remote.exec "sudo test-snapd-curl.curl -X POST --unix-socket /run/snapd.socket -F 'action=create' -F 'label=new-system' -F 'validation-sets=test-snapd/recovery-system-pinned=1' -F 'assertion= modeenv + MATCH 'current_recovery_systems=.*,new-system$' < modeenv + MATCH 'good_recovery_systems=.*,new-system$' < modeenv + + remote.exec sudo snap recovery | MATCH 'new-system' + + remote.exec "test -f /var/lib/snapd/seed/snaps/hello-world_28.snap" + + # reboot into the new system. don't explicitly use the label, as this newly + # created system should be the default + remote.exec 'sudo snap reboot --recover' + remote.wait-for reboot "${boot_id}" + + remote.wait-for snap-command + + wait_for_first_boot_change + + # since hello-world has ['run', 'ephemeral'] as its modes in the model, it + # will be here. additionally, it will be pinned to revision 28 because of the + # validation set that was used to create the recovery system. + retry -n 10 --wait 1 sh -c "remote.exec 'snap list hello-world' | awk 'NR != 1 { print \$3 }' | MATCH '28'" + + remote.exec 'cat /proc/cmdline' | MATCH 'snapd_recovery_mode=recover' + remote.exec 'sudo cat /var/lib/snapd/modeenv' > modeenv + MATCH 'mode=recover' < modeenv + MATCH 'recovery_system=new-system' < modeenv diff --git a/tests/nested/manual/recovery-system-reboot/task.yaml b/tests/nested/manual/recovery-system-reboot/task.yaml new file mode 100644 index 00000000000..e93ab3a0482 --- /dev/null +++ b/tests/nested/manual/recovery-system-reboot/task.yaml @@ -0,0 +1,118 @@ +summary: create a recovery system and reboot into it +details: | + This test creates a recovery system and validates that the newly created + system can be rebooted into. + +systems: [ubuntu-22.04-64] + +environment: + NESTED_CUSTOM_MODEL: $TESTSLIB/assertions/test-snapd-recovery-system-pc-22.model + NESTED_ENABLE_TPM: true + NESTED_ENABLE_SECURE_BOOT: true + NESTED_BUILD_SNAPD_FROM_CURRENT: true + NESTED_REPACK_GADGET_SNAP: false + NESTED_REPACK_KERNEL_SNAP: false + NESTED_REPACK_BASE_SNAP: false + NESTED_FORCE_MS_KEYS: true + + MODE/recover: "recover" + MODE/factory_reset: "factory-reset" + MODE/install: "install" + + # TODO: figure out a way to do this test without disabling secure boot and TMP + # see tests/nested/core/core20-reinstall-partitions/task.yaml for more details + NESTED_ENABLE_SECURE_BOOT/install: false + NESTED_ENABLE_TPM/install: false + +prepare: | + tests.nested build-image core + tests.nested create-vm core + +execute: | + function post_json_data() { + route=$1 + template=$2 + shift 2 + + # shellcheck disable=SC2059 + response=$(printf "${template}" "$@" | remote.exec "sudo test-snapd-curl.curl -X POST -H 'Content-Type: application/json' --unix-socket /run/snapd.socket -d @- http://localhost${route}") + if ! jq -e .change <<< "${response}"; then + echo "could not get change id from response: ${response}" + false + fi + } + + #shellcheck source=tests/lib/core-config.sh + . "$TESTSLIB"/core-config.sh + + wait_for_first_boot_change + + remote.exec sudo snap install --edge --devmode test-snapd-curl + + boot_id="$(tests.nested boot-id)" + + prev_system=$(remote.exec 'sudo snap recovery' | awk 'NR != 1 { print $1 }') + + # create the system + change_id=$(post_json_data /v2/systems '{"action": "create", "label": "new-system", "validation-sets": ["test-snapd/test-snapd-pinned-essential-snaps=1"], "mark-default": true, "test-system": true}') + + # wait for reboot since we tested the system + remote.wait-for reboot "${boot_id}" + boot_id="$(tests.nested boot-id)" + + remote.wait-for snap-command + + remote.exec snap watch "${change_id}" + + remote.exec 'test -d /run/mnt/ubuntu-seed/systems/new-system' + remote.exec 'sudo cat /var/lib/snapd/modeenv' > modeenv + MATCH 'current_recovery_systems=.*,new-system$' < modeenv + MATCH 'good_recovery_systems=.*,new-system$' < modeenv + + remote.exec 'sudo snap recovery' | awk '$1 == "new-system" { print $4 }' | MATCH 'default-recovery' + + remote.exec "sudo snap reboot --${MODE}" || true + remote.wait-for reboot "${boot_id}" + + remote.wait-for snap-command + wait_for_first_boot_change + + # wait for the system to finish being seeded + remote.exec "sudo snap wait system seed.loaded" + + # hold everything so that we can check their revisions before they get auto-refreshed + remote.exec "snap list | awk 'NR != 1 { print \$1 }' | xargs sudo snap refresh --hold" + + if [ "${MODE}" = 'recover' ]; then + remote.exec 'cat /proc/cmdline' | MATCH 'snapd_recovery_mode=recover' + remote.exec 'sudo cat /var/lib/snapd/modeenv' > modeenv + MATCH 'mode=recover' < modeenv + MATCH 'recovery_system=new-system' < modeenv + elif [ "${MODE}" = 'factory-reset' ] || [ "${MODE}" = "install" ]; then + # should be back into run mode since we reset the device + remote.exec cat /proc/cmdline | MATCH 'snapd_recovery_mode=run' + + # new system should be the default recovery system and the current system + remote.exec 'sudo snap recovery' | awk '$1 == "new-system" { print $4 }' | MATCH 'current,default-recovery' + + remote.exec sudo snap install --edge --devmode test-snapd-curl + + # since out new system is now the default and the current recovery system, + # we should be able to remove the old one + + # sometimes, this will conflict with an auto-refresh change. retry just in + # case. + export -f post_json_data + retry -n 10 --wait 2 bash -c "post_json_data '/v2/systems/${prev_system}' '{\"action\": \"remove\"}'" + + remote.exec "snap watch --last=remove-recovery-system" + remote.exec "sudo snap recovery" | NOMATCH "${prev_system}" + fi + + # since hello-world has ['run', 'ephemeral'] as its modes in the model, it + # should be here in all tested modes. + remote.exec 'snap list hello-world' + + remote.exec 'snap list core22' | awk 'NR != 1 { print $3 }' | MATCH '1033' + remote.exec 'snap list pc' | awk 'NR != 1 { print $3 }' | MATCH '145' + remote.exec 'snap list pc-kernel' | awk 'NR != 1 { print $3 }' | MATCH '1606' diff --git a/tests/nested/manual/recovery-system/task.yaml b/tests/nested/manual/recovery-system/task.yaml new file mode 100644 index 00000000000..f6ffc9bfe19 --- /dev/null +++ b/tests/nested/manual/recovery-system/task.yaml @@ -0,0 +1,97 @@ +summary: create and remove a recovery system using the API +details: | + This test creates and removes a recovery system using the recovery system API. + A few variants are tested, including testing the new recovery system and + marking it as the default recovery system. + +systems: [ubuntu-20.04-64, ubuntu-22.04-64] + +environment: + NESTED_CUSTOM_MODEL: $TESTSLIB/assertions/test-snapd-recovery-system-pc-{VERSION}.model + NESTED_ENABLE_TPM: true + NESTED_ENABLE_SECURE_BOOT: true + NESTED_BUILD_SNAPD_FROM_CURRENT: true + + TEST_SYSTEM: false + MARK_DEFAULT: false + + TEST_SYSTEM/no_test_or_default: false + MARK_DEFAULT/no_test_or_default: false + + TEST_SYSTEM/tested: true + + MARK_DEFAULT/default: true + + TEST_SYSTEM/tested_and_default: true + MARK_DEFAULT/tested_and_default: true + +prepare: | + tests.nested build-image core + tests.nested create-vm core + +execute: | + function post_json_data() { + route=$1 + template=$2 + shift 2 + + # shellcheck disable=SC2059 + response=$(printf "${template}" "$@" | remote.exec "sudo test-snapd-curl.curl -X POST -H 'Content-Type: application/json' --unix-socket /run/snapd.socket -d @- http://localhost${route}") + if ! jq -e .change <<< "${response}"; then + echo "could not get change id from response: ${response}" + false + fi + } + + #shellcheck source=tests/lib/core-config.sh + . "$TESTSLIB"/core-config.sh + + wait_for_first_boot_change + + remote.exec sudo snap install --edge --devmode test-snapd-curl + + boot_id="$(tests.nested boot-id)" + + # create the system + change_id=$(post_json_data /v2/systems '{"action": "create", "label": "new-system", "validation-sets": ["test-snapd/recovery-system-pinned=1"], "test-system": %s, "mark-default": %s}' "${TEST_SYSTEM}" "${MARK_DEFAULT}") + + if [ "${TEST_SYSTEM}" = 'true' ]; then + remote.wait-for reboot "${boot_id}" + remote.exec 'sudo cat /proc/cmdline' | MATCH 'snapd_recovery_mode=run' + fi + + remote.exec snap watch "${change_id}" + + # check that the new label was appended to the current and good recovery + # system lists + remote.exec 'test -d /run/mnt/ubuntu-seed/systems/new-system' + remote.exec 'sudo cat /var/lib/snapd/modeenv' > modeenv + MATCH 'current_recovery_systems=.*,new-system$' < modeenv + MATCH 'good_recovery_systems=.*,new-system$' < modeenv + + remote.exec sudo snap recovery | MATCH 'new-system' + + if [ "${MARK_DEFAULT}" = 'true' ]; then + remote.exec 'sudo snap recovery' | awk '$1 == "new-system" { print $4 }' | MATCH 'default-recovery' + fi + + remote.exec "test -f /var/lib/snapd/seed/snaps/hello-world_28.snap" + + # remove the system + change_id=$(post_json_data /v2/systems/new-system '{"action": "remove"}') + + if [ "${MARK_DEFAULT}" = 'true' ]; then + # task should fail if we try and remove the default system + remote.exec "! snap watch ${change_id}" + exit 0 + fi + + remote.exec "snap watch ${change_id}" + + remote.exec "! test -f /var/lib/snapd/seed/snaps/hello-world_28.snap" + remote.exec '! test -d /run/mnt/ubuntu-seed/systems/new-system' + remote.exec 'sudo cat /var/lib/snapd/modeenv' > modeenv + NOMATCH 'current_recovery_systems=.*,new-system$' < modeenv + NOMATCH 'good_recovery_systems=.*,new-system$' < modeenv + + remote.exec sudo snap recovery | NOMATCH 'new-system'