diff --git a/daemon/api_sideload_n_try_test.go b/daemon/api_sideload_n_try_test.go index 542f8a9a157..b1288718443 100644 --- a/daemon/api_sideload_n_try_test.go +++ b/daemon/api_sideload_n_try_test.go @@ -2026,7 +2026,7 @@ func (s *sideloadSuite) TestSideloadManyOnlyComponents(c *check.C) { st.Lock() defer st.Unlock() - expectedFileNames := []string{"one+comp-one.comp.comp", "one+comp-two.comp.comp", "one+comp-three.comp.comp", "one+comp-four.comp.comp"} + expectedFileNames := []string{"one+comp-one.comp", "one+comp-two.comp", "one+comp-three.comp", "one+comp-four.comp"} fullComponentNames := make([]string, len(components)) for i, c := range components { diff --git a/overlord/devicestate/devicestate.go b/overlord/devicestate/devicestate.go index a6f31e9ce3a..f91c815f4f7 100644 --- a/overlord/devicestate/devicestate.go +++ b/overlord/devicestate/devicestate.go @@ -63,6 +63,7 @@ var ( snapstateSwitch = snapstate.Switch snapstateUpdatePathWithDeviceContext = snapstate.UpdatePathWithDeviceContext snapstateDownload = snapstate.Download + snapstateDownloadComponents = snapstate.DownloadComponents ) // findModel returns the device model assertion. @@ -1150,7 +1151,9 @@ func remodelTasks(ctx context.Context, st *state.State, current, new *asserts.Mo } // we don't pass in the list of local snaps here because they are // already represented by snapSetupTasks - createRecoveryTasks, err := createRecoverySystemTasks(st, label, snapSetupTasks, CreateRecoverySystemOptions{ + + // TODO:COMPS - pass in the list of component setup tasks + createRecoveryTasks, err := createRecoverySystemTasks(st, label, snapSetupTasks, nil, CreateRecoverySystemOptions{ TestSystem: true, }) if err != nil { @@ -1498,10 +1501,14 @@ type recoverySystemSetup struct { // SnapSetupTasks is a list of task IDs that carry snap setup information. // Tasks could come from a remodel, or from downloading snaps that were // required by a validation set. - SnapSetupTasks []string `json:"snap-setup-tasks"` + SnapSetupTasks []string `json:"snap-setup-tasks,omitempty"` // LocalSnaps is a list of snaps that should be used to create the recovery // system. LocalSnaps []LocalSnap `json:"local-snaps,omitempty"` + // ComponentSetupTasks is a list of task IDs that carry component setup + // information. Tasks could come from a remodel, or from downloading + // components that were required by a validation set. + ComponentSetupTasks []string `json:"component-setup-tasks,omitempty"` // TestSystem is set to true if the new recovery system should // not be verified by rebooting into the new system. Once the system is // created, it will immediately be considered a valid recovery system. @@ -1553,7 +1560,7 @@ func removeRecoverySystemTasks(st *state.State, label string) (*state.TaskSet, e return state.NewTaskSet(remove), nil } -func createRecoverySystemTasks(st *state.State, label string, snapSetupTasks []string, opts CreateRecoverySystemOptions) (*state.TaskSet, error) { +func createRecoverySystemTasks(st *state.State, label string, snapSetupTasks, compSetupTasks []string, opts CreateRecoverySystemOptions) (*state.TaskSet, error) { // precondition check, the directory should not exist yet systemDirectory := filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", label) exists, _, err := osutil.DirExists(systemDirectory) @@ -1570,10 +1577,11 @@ func createRecoverySystemTasks(st *state.State, label string, snapSetupTasks []s Label: label, Directory: systemDirectory, // IDs of the tasks carrying snap-setup - SnapSetupTasks: snapSetupTasks, - LocalSnaps: opts.LocalSnaps, - TestSystem: opts.TestSystem, - MarkDefault: opts.MarkDefault, + SnapSetupTasks: snapSetupTasks, + ComponentSetupTasks: compSetupTasks, + LocalSnaps: opts.LocalSnaps, + TestSystem: opts.TestSystem, + MarkDefault: opts.MarkDefault, }) ts := state.NewTaskSet(create) @@ -1667,6 +1675,21 @@ func RemoveRecoverySystem(st *state.State, label string) (*state.Change, error) return chg, nil } +func checkForRequiredSnapsNotPresentInModel(model *asserts.Model, vSets *snapasserts.ValidationSets) error { + snapsInModel := make(map[string]bool, len(model.AllSnaps())) + for _, sn := range model.AllSnaps() { + snapsInModel[sn.SnapName()] = true + } + + for _, sn := range vSets.RequiredSnaps() { + if !snapsInModel[sn] { + return fmt.Errorf("missing required snap in model: %s", sn) + } + } + + return nil +} + // CreateRecoverySystem creates a new recovery system with the given label. See // CreateRecoverySystemOptions for details on the options that can be provided. func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySystemOptions) (chg *state.Change, err error) { @@ -1705,11 +1728,6 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst return nil, err } - revisions, err := valsets.Revisions() - if err != nil { - return nil, err - } - // TODO: this restriction should be lifted eventually (in the case that we // have a dangerous model), and we should fall back to using snap names in // places that IDs are used @@ -1722,64 +1740,118 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst return nil, err } + // the task that creates the recovery system doesn't know anything about + // validation sets, so we cannot create systems with snaps that are not in + // the model. + if err := checkForRequiredSnapsNotPresentInModel(model, valsets); err != nil { + return nil, err + } + tracker := snap.NewSelfContainedSetPrereqTracker() + validRevision := func(current snap.Revision, constraints snapasserts.PresenceConstraint) bool { + return constraints.Revision.Unset() || current == constraints.Revision + } + var downloadTSS []*state.TaskSet for _, sn := range model.AllSnaps() { - rev := revisions[sn.Name] + constraints, err := valsets.Presence(sn) + if err != nil { + return nil, err + } - needsInstall, err := snapNeedsInstall(st, sn.Name, rev) + installed, currentRevision, err := installedSnapRevision(st, sn.Name) if err != nil { return nil, err } - if !needsInstall { - info, err := snapstate.CurrentInfo(st, sn.Name) - if err != nil { - return nil, err - } - tracker.Add(info) + // if the snap is installed, then we must either download it from the + // store, have it provided locally, or it must be installed at the + // correct revision. + // + // TODO: in the case that the snap is installed at the wrong revision, + // we must provide it either from the store or locally. this is because + // doCreateRecoverySystem will install any optional snaps that are + // present on the system. + required := constraints.Presence == asserts.PresenceRequired || sn.Presence == "required" || installed + if !required { continue } - if sn.Presence != "required" { - pres, err := valsets.Presence(sn) + compsToDownload := make([]string, 0, len(sn.Components)) + for name, comp := range sn.Components { + compInstalled, currentCompRevision, err := installedComponentRevision(st, sn.Name, name) if err != nil { return nil, err } - // snap isn't already installed, and it isn't required by model or - // any validation sets, so we should skip it - if pres.Presence != asserts.PresenceRequired { + compConstraints := constraints.Component(name) + + required := comp.Presence == "required" || constraints.Component(name).Presence == asserts.PresenceRequired || compInstalled + + // same deal as with snaps, same TODO as well + if !required { continue } + + switch { + case compInstalled && validRevision(currentCompRevision, compConstraints): + // nothing to do! + case opts.Offline: + // TODO: verify that we have the offline component + default: + compsToDownload = append(compsToDownload, name) + } } - if opts.Offline { - info, err := offlineSnapInfo(sn, rev, opts) + switch { + case installed && validRevision(currentRevision, constraints.PresenceConstraint): + info, err := snapstate.CurrentInfo(st, sn.Name) + if err != nil { + return nil, err + } + tracker.Add(info) + case opts.Offline: + info, err := offlineSnapInfo(sn, constraints.Revision, opts) if err != nil { return nil, err } tracker.Add(info) + default: + // TODO: this respects the passed in validation sets, but does not + // currently respect refresh-control style of constraining snap + // revisions. + // + // TODO: download somewhere other than the default snap blob dir. + ts, _, err := snapstateDownload(context.TODO(), st, sn.Name, compsToDownload, dirs.SnapBlobDir, snapstate.RevisionOptions{ + Channel: sn.DefaultChannel, + ValidationSets: valsets, + }, snapstate.Options{ + PrereqTracker: tracker, + }) + if err != nil { + return nil, err + } + downloadTSS = append(downloadTSS, ts) + + // if we go in this branch, then we'll handle downloading snaps and + // components at the same time. continue } - // TODO: this respects the passed in validation sets, but does not - // currently respect refresh-control style of constraining snap - // revisions. - // - // TODO: download somewhere other than the default snap blob dir. - ts, info, err := snapstateDownload(context.TODO(), st, sn.Name, nil, dirs.SnapBlobDir, snapstate.RevisionOptions{ - Channel: sn.DefaultChannel, - Revision: rev, - ValidationSets: valsets, - }, snapstate.Options{}) - if err != nil { - return nil, err + if len(compsToDownload) > 0 { + // TODO: download somewhere other than the default snap blob dir. + ts, err := snapstateDownloadComponents(context.TODO(), st, sn.Name, compsToDownload, dirs.SnapBlobDir, snapstate.RevisionOptions{ + Channel: sn.DefaultChannel, + ValidationSets: valsets, + }, snapstate.Options{ + PrereqTracker: tracker, + }) + if err != nil { + return nil, err + } + downloadTSS = append(downloadTSS, ts) } - - tracker.Add(info) - downloadTSS = append(downloadTSS, ts) } warnings, errs := tracker.Check() @@ -1787,7 +1859,6 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst logger.Noticef("create recovery system prerequisites warning: %v", w) } - // TODO: use function from other branch if len(errs) > 0 { var builder strings.Builder builder.WriteString("cannot create recovery system from model that is not self-contained:") @@ -1800,16 +1871,13 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst return nil, errors.New(builder.String()) } - var snapsupTaskIDs []string - if len(downloadTSS) > 0 { - snapsupTaskIDs, err = extractSnapSetupTaskIDs(downloadTSS) - if err != nil { - return nil, err - } + snapsupTaskIDs, compsupTaskIDs, err := extractSnapSetupTaskIDs(downloadTSS) + if err != nil { + return nil, err } chg = st.NewChange("create-recovery-system", fmt.Sprintf("Create new recovery system with label %q", label)) - createTS, err := createRecoverySystemTasks(st, label, snapsupTaskIDs, opts) + createTS, err := createRecoverySystemTasks(st, label, snapsupTaskIDs, compsupTaskIDs, opts) if err != nil { return nil, err } @@ -1870,39 +1938,59 @@ func offlineSnapInfo(sn *asserts.ModelSnap, rev snap.Revision, opts CreateRecove return snap.ReadInfoFromSnapFile(s, localSnap.SideInfo) } -func snapNeedsInstall(st *state.State, name string, rev snap.Revision) (bool, error) { - info, err := snapstate.CurrentInfo(st, name) - if err != nil { - if isNotInstalled(err) { - return true, nil +func installedSnapRevision(st *state.State, name string) (bool, snap.Revision, error) { + var snapst snapstate.SnapState + if err := snapstate.Get(st, name, &snapst); err != nil { + if errors.Is(err, state.ErrNoState) { + return false, snap.Revision{}, nil } - return false, err + return false, snap.Revision{}, err } + return true, snapst.Current, nil +} - if rev.Unset() { - return false, nil +func installedComponentRevision(st *state.State, snapName, compName string) (bool, snap.Revision, error) { + var snapst snapstate.SnapState + if err := snapstate.Get(st, snapName, &snapst); err != nil { + if errors.Is(err, state.ErrNoState) { + return false, snap.Revision{}, nil + } + return false, snap.Revision{}, err + } + + for _, comp := range snapst.CurrentComponentSideInfos() { + if comp.Component.ComponentName == compName { + return true, comp.Revision, nil + } } - return rev != info.Revision, nil + return false, snap.Revision{}, nil } -func extractSnapSetupTaskIDs(tss []*state.TaskSet) ([]string, error) { - var taskIDs []string +func extractSnapSetupTaskIDs(tss []*state.TaskSet) (snapsupTaskIDs, compsupTaskIDs []string, err error) { for _, ts := range tss { - found := false + var snapsupTask *state.Task for _, t := range ts.Tasks() { if t.Has("snap-setup") { - taskIDs = append(taskIDs, t.ID()) - found = true + snapsupTask = t break } } - if !found { - return nil, errors.New("internal error: snap setup task missing from task set") + if snapsupTask == nil { + return nil, nil, errors.New("internal error: snap setup task missing from task set") } + + snapsupTaskIDs = append(snapsupTaskIDs, snapsupTask.ID()) + + var compsups []string + if err := snapsupTask.Get("component-setup-tasks", &compsups); err != nil && !errors.Is(err, state.ErrNoState) { + return nil, nil, err + } + + compsupTaskIDs = append(compsupTaskIDs, compsups...) } - return taskIDs, nil + return snapsupTaskIDs, compsupTaskIDs, nil } // OptionalContainers is used to define the snaps and components that are diff --git a/overlord/devicestate/devicestate_remodel_test.go b/overlord/devicestate/devicestate_remodel_test.go index b94874307e9..4fc5bbffd63 100644 --- a/overlord/devicestate/devicestate_remodel_test.go +++ b/overlord/devicestate/devicestate_remodel_test.go @@ -4282,10 +4282,9 @@ func (s *deviceMgrRemodelSuite) TestRemodelUC20EssentialSnapsTrackingDifferentCh err = tCreateRecovery.Get("recovery-system-setup", &systemSetupData) c.Assert(err, IsNil) c.Assert(systemSetupData, DeepEquals, map[string]interface{}{ - "label": expectedLabel, - "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), - "snap-setup-tasks": nil, - "test-system": true, + "label": expectedLabel, + "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), + "test-system": true, }) } @@ -4623,10 +4622,9 @@ func (s *deviceMgrRemodelSuite) TestRemodelUC20BaseNoDownloadSimpleChannelSwitch err = tCreateRecovery.Get("recovery-system-setup", &systemSetupData) c.Assert(err, IsNil) c.Assert(systemSetupData, DeepEquals, map[string]interface{}{ - "label": expectedLabel, - "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), - "snap-setup-tasks": nil, - "test-system": true, + "label": expectedLabel, + "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), + "test-system": true, }) } diff --git a/overlord/devicestate/devicestate_systems_test.go b/overlord/devicestate/devicestate_systems_test.go index 82b6ca523ba..6098fca05dd 100644 --- a/overlord/devicestate/devicestate_systems_test.go +++ b/overlord/devicestate/devicestate_systems_test.go @@ -50,6 +50,7 @@ import ( "github.com/snapcore/snapd/overlord/install" "github.com/snapcore/snapd/overlord/restart" "github.com/snapcore/snapd/overlord/snapstate" + "github.com/snapcore/snapd/overlord/snapstate/sequence" "github.com/snapcore/snapd/overlord/snapstate/snapstatetest" "github.com/snapcore/snapd/overlord/state" "github.com/snapcore/snapd/release" @@ -1379,10 +1380,10 @@ func (s *deviceMgrSystemsCreateSuite) SetUpTest(c *C) { s.state.Lock() defer s.state.Unlock() - s.makeSnapInState(c, "pc", snap.R(1), nil) - s.makeSnapInState(c, "pc-kernel", snap.R(2), nil) - s.makeSnapInState(c, "core20", snap.R(3), nil) - s.makeSnapInState(c, "snapd", snap.R(4), nil) + s.makeSnapInState(c, "pc", snap.R(1), nil, nil) + s.makeSnapInState(c, "pc-kernel", snap.R(2), nil, nil) + s.makeSnapInState(c, "core20", snap.R(3), nil, nil) + s.makeSnapInState(c, "snapd", snap.R(4), nil, nil) s.bootloader = s.deviceMgrSystemsBaseSuite.bootloader.WithRecoveryAwareTrustedAssets() bootloader.Force(s.bootloader) @@ -1434,10 +1435,9 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemTasks err = tskCreate.Get("recovery-system-setup", &systemSetupData) c.Assert(err, IsNil) c.Assert(systemSetupData, DeepEquals, map[string]interface{}{ - "label": "1234", - "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems/1234"), - "snap-setup-tasks": nil, - "test-system": true, + "label": "1234", + "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems/1234"), + "test-system": true, }) var otherTaskID string @@ -1470,7 +1470,59 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemNotSe c.Check(chg, IsNil) } -func (s *deviceMgrSystemsCreateSuite) makeSnapInState(c *C, name string, rev snap.Revision, extraFiles [][]string) *snap.Info { +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoveryRequiredInVsetNotInModel(c *C) { + devicestate.SetBootOkRan(s.mgr, true) + + s.state.Lock() + defer s.state.Unlock() + + s.model = s.brands.Model("canonical", "pc-20", map[string]interface{}{ + "architecture": "amd64", + "grade": "dangerous", + "base": "core20", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": s.ss.AssertedSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "20", + }, + map[string]interface{}{ + "name": "pc", + "id": s.ss.AssertedSnapID("pc"), + "type": "gadget", + "default-channel": "20", + }, + }, + "revision": "2", + }) + + vset, err := s.brands.Signing("canonical").Sign(asserts.ValidationSetType, map[string]interface{}{ + "type": "validation-set", + "authority-id": "canonical", + "series": "16", + "account-id": "canonical", + "name": "vset-1", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "required-snap", + "id": s.ss.AssertedSnapID("other"), + "presence": "required", + }, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + + chg, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ + ValidationSets: []*asserts.ValidationSet{vset.(*asserts.ValidationSet)}, + }) + c.Assert(err, ErrorMatches, `missing required snap in model: required-snap`) + c.Check(chg, IsNil) +} + +func (s *deviceMgrSystemsCreateSuite) makeSnapInState(c *C, name string, rev snap.Revision, extraFiles [][]string, components map[string]snap.Revision) *snap.Info { snapID := s.ss.AssertedSnapID(name) if rev.Unset() || rev.Local() { snapID = "" @@ -1489,10 +1541,62 @@ func (s *deviceMgrSystemsCreateSuite) makeSnapInState(c *C, name string, rev sna s.setupSnapDecl(c, info, "canonical") s.setupSnapRevision(c, info, "canonical", rev) } + + seq := snapstatetest.NewSequenceFromSnapSideInfos([]*snap.SideInfo{si}) + + for comp, compRev := range components { + if rev.Unset() { + continue + } + + cref := naming.NewComponentRef(name, comp) + + compYaml, ok := componentYamls[cref.String()] + c.Assert(ok, Equals, true, Commentf("component.yaml not found for %q", name)) + + compPath := snaptest.MakeTestComponent(c, compYaml) + + csi := snap.ComponentSideInfo{ + Component: cref, + Revision: compRev, + } + + compInfo := snaptest.MockComponent(c, compYaml, info, csi) + + cpi := snap.MinimalComponentContainerPlaceInfo( + comp, + compRev, + name, + ) + err := os.Rename(compPath, cpi.MountFile()) + c.Assert(err, IsNil) + + s.setupSnapResourcePair( + c, + comp, + snapID, + "canonical", + compRev, + rev, + ) + + s.setupSnapResourceRevision( + c, + cpi.MountFile(), + comp, + snapID, + "canonical", + compRev, + ) + + err = seq.AddComponentForRevision(rev, sequence.NewComponentState(snap.NewComponentSideInfo(cref, compRev), compInfo.Type)) + c.Assert(err, IsNil) + } + snapstate.Set(s.state, info.InstanceName(), &snapstate.SnapState{ SnapType: string(info.Type()), Active: true, - Sequence: snapstatetest.NewSequenceFromSnapSideInfos([]*snap.SideInfo{si}), + Sequence: seq, Current: si.Revision, }) @@ -1500,10 +1604,10 @@ func (s *deviceMgrSystemsCreateSuite) makeSnapInState(c *C, name string, rev sna } func (s *deviceMgrSystemsCreateSuite) mockStandardSnapsModeenvAndBootloaderState(c *C) { - s.makeSnapInState(c, "pc", snap.R(1), nil) - s.makeSnapInState(c, "pc-kernel", snap.R(2), nil) - s.makeSnapInState(c, "core20", snap.R(3), nil) - s.makeSnapInState(c, "snapd", snap.R(4), nil) + s.makeSnapInState(c, "pc", snap.R(1), nil, nil) + s.makeSnapInState(c, "pc-kernel", snap.R(2), nil, nil) + s.makeSnapInState(c, "core20", snap.R(3), nil, nil) + s.makeSnapInState(c, "snapd", snap.R(4), nil, nil) err := s.bootloader.SetBootVars(map[string]string{ "snap_kernel": "pc-kernel_2.snap", @@ -1662,7 +1766,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemRemod tSnapsup1.Set("snap-setup", snapsupFoo) tSnapsup2.Set("snap-setup", snapsupBar) - tss, err := devicestate.CreateRecoverySystemTasks(s.state, "1234", []string{tSnapsup1.ID(), tSnapsup2.ID()}, devicestate.CreateRecoverySystemOptions{ + tss, err := devicestate.CreateRecoverySystemTasks(s.state, "1234", []string{tSnapsup1.ID(), tSnapsup2.ID()}, nil, devicestate.CreateRecoverySystemOptions{ TestSystem: true, }) c.Assert(err, IsNil) @@ -1827,7 +1931,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemRemod s.state.Lock() - tss, err := devicestate.CreateRecoverySystemTasks(s.state, "1234", nil, devicestate.CreateRecoverySystemOptions{ + tss, err := devicestate.CreateRecoverySystemTasks(s.state, "1234", nil, nil, devicestate.CreateRecoverySystemOptions{ TestSystem: true, }) c.Assert(err, IsNil) @@ -1842,10 +1946,9 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemRemod err = tskCreate.Get("recovery-system-setup", &systemSetupData) c.Assert(err, IsNil) c.Assert(systemSetupData, DeepEquals, map[string]interface{}{ - "label": "1234", - "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems/1234"), - "snap-setup-tasks": nil, - "test-system": true, + "label": "1234", + "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems/1234"), + "test-system": true, }) // add the test tasks to the change chg := s.state.NewChange("create-recovery-system", "create recovery system") @@ -2042,7 +2145,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemRemod } tSnapsup1.Set("snap-setup", snapsupFoo) - tss, err := devicestate.CreateRecoverySystemTasks(s.state, "1234missingdownload", []string{tSnapsup1.ID()}, devicestate.CreateRecoverySystemOptions{ + tss, err := devicestate.CreateRecoverySystemTasks(s.state, "1234missingdownload", []string{tSnapsup1.ID()}, nil, devicestate.CreateRecoverySystemOptions{ TestSystem: true, }) c.Assert(err, IsNil) @@ -3381,7 +3484,7 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid validationSets = append(validationSets, vsetAssert.(*asserts.ValidationSet)) if opts.PreInstallOptionalSnap { - s.makeSnapInState(c, "other-required", snapRevisions["other-required"], nil) + s.makeSnapInState(c, "other-required", snapRevisions["other-required"], nil, nil) } if opts.RequireOptionalSnapInValidationSet { @@ -3472,13 +3575,13 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid return nil, nil, fmt.Errorf("unexpected snap name %q", name) } - c.Check(expectedRev, Equals, revOpts.Revision) + c.Check(revOpts.Revision.Unset(), Equals, true) tDownload := s.state.NewTask("mock-download", fmt.Sprintf("Download %s to track %s", name, revOpts.Channel)) si := &snap.SideInfo{ RealName: name, - Revision: revOpts.Revision, + Revision: expectedRev, SnapID: fakeSnapID(name), } tDownload.Set("snap-setup", &snapstate.SnapSetup{ @@ -3489,6 +3592,8 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid _, info := snaptest.MakeTestSnapInfoWithFiles(c, snapYamls[name], snapFiles[name], si) + opts.PrereqTracker.Add(info) + tValidate := s.state.NewTask("mock-validate", fmt.Sprintf("Validate %s", name)) tValidate.Set("snap-setup-task", tDownload.ID()) @@ -3634,6 +3739,823 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid } } +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsComponents(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.testDeviceManagerCreateRecoverySystemValidationSetsComponents(c, testCreateRecoverySystemValidationSetsComponentsOpts{ + kmodModelPresence: "required", + kmodVsetPresence: "required", + blobs: []string{"snapd_13.snap", "pc-kernel-with-kmods_11.snap", "pc-kernel-with-kmods+kmod_20.comp", "core20_12.snap", "pc_10.snap"}, + downloadedSnaps: 4, + downloadedComps: 1, + }) +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsComponentsRequiredInVsets(c *C) { + s.state.Lock() + defer s.state.Unlock() + + // should still download and install the component, despite the correct snap + // being present + s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, nil) + + s.testDeviceManagerCreateRecoverySystemValidationSetsComponents(c, testCreateRecoverySystemValidationSetsComponentsOpts{ + kmodModelPresence: "optional", + kmodVsetPresence: "required", + blobs: []string{"snapd_13.snap", "pc-kernel-with-kmods_11.snap", "pc-kernel-with-kmods+kmod_20.comp", "core20_12.snap", "pc_10.snap"}, + downloadedSnaps: 3, // snapd, core20, pc + downloadedComps: 1, // pc-kernel-with-kmods+kmod + }) +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsComponentsNoInstall(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.testDeviceManagerCreateRecoverySystemValidationSetsComponents(c, testCreateRecoverySystemValidationSetsComponentsOpts{ + kmodModelPresence: "optional", + kmodVsetPresence: "optional", + blobs: []string{"snapd_13.snap", "pc-kernel-with-kmods_11.snap", "core20_12.snap", "pc_10.snap"}, + downloadedSnaps: 4, + downloadedComps: 0, + }) +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsComponentsAlreadyInstalledComponent(c *C) { + s.state.Lock() + defer s.state.Unlock() + + // snap and components are already installed, but this component revision is + // wrong. everything should still happen as if the snap was not installed. + s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, map[string]snap.Revision{ + "kmod": snap.R(19), + }) + + s.testDeviceManagerCreateRecoverySystemValidationSetsComponents(c, testCreateRecoverySystemValidationSetsComponentsOpts{ + kmodModelPresence: "required", + kmodVsetPresence: "required", + blobs: []string{"snapd_13.snap", "pc-kernel-with-kmods_11.snap", "pc-kernel-with-kmods+kmod_20.comp", "core20_12.snap", "pc_10.snap"}, + downloadedSnaps: 3, // snapd, core20, pc + downloadedComps: 1, // pc-kernel-with-kmods+kmod + }) +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsComponentsAlreadyInstalledComponentOptional(c *C) { + s.state.Lock() + defer s.state.Unlock() + + // even though the component is optional, we still download it since it is + // installed on the current system. + s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, map[string]snap.Revision{ + "kmod": snap.R(19), + }) + + s.testDeviceManagerCreateRecoverySystemValidationSetsComponents(c, testCreateRecoverySystemValidationSetsComponentsOpts{ + kmodModelPresence: "optional", + kmodVsetPresence: "optional", + blobs: []string{"snapd_13.snap", "pc-kernel-with-kmods_11.snap", "pc-kernel-with-kmods+kmod_20.comp", "core20_12.snap", "pc_10.snap"}, + downloadedSnaps: 3, // snapd, core20, pc + downloadedComps: 1, // pc-kernel-with-kmods+kmod + }) +} + +type testCreateRecoverySystemValidationSetsComponentsOpts struct { + kmodModelPresence string + kmodVsetPresence string + blobs []string + downloadedSnaps int + downloadedComps int +} + +func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValidationSetsComponents(c *C, opts testCreateRecoverySystemValidationSetsComponentsOpts) { + devicestate.SetBootOkRan(s.mgr, true) + + snapComponents := map[string][]string{ + "pc-kernel-with-kmods": {"kmod"}, + } + + s.model = s.makeModelAssertionInState(c, "canonical", "pc-20", map[string]interface{}{ + "architecture": "amd64", + "grade": "dangerous", + "base": "core20", + "revision": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel-with-kmods", + "id": s.ss.AssertedSnapID("pc-kernel-with-kmods"), + "type": "kernel", + "default-channel": "20", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "presence": opts.kmodModelPresence, + }, + "other-kmod": map[string]interface{}{ + "presence": "optional", + }, + }, + }, + map[string]interface{}{ + "name": "pc", + "id": s.ss.AssertedSnapID("pc"), + "type": "gadget", + "default-channel": "20", + }, + map[string]interface{}{ + "name": "core20", + "id": s.ss.AssertedSnapID("core20"), + "type": "base", + }, + map[string]interface{}{ + "name": "snapd", + "id": s.ss.AssertedSnapID("snapd"), + "type": "snapd", + }, + }, + "validation-sets": []interface{}{ + map[string]interface{}{ + "account-id": "canonical", + "name": "vset-model", + "mode": "enforce", + }, + }, + }) + + vsetModel, err := s.brands.Signing("canonical").Sign(asserts.ValidationSetType, map[string]interface{}{ + "type": "validation-set", + "authority-id": "canonical", + "series": "16", + "account-id": "canonical", + "name": "vset-model", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel-with-kmods", + "id": fakeSnapID("pc-kernel-with-kmods"), + "presence": "required", + "revision": "11", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "revision": "20", + "presence": opts.kmodVsetPresence, + }, + }, + }, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + + assertstatetest.AddMany(s.state, vsetModel) + assertstate.UpdateValidationSet(s.state, &assertstate.ValidationSetTracking{ + AccountID: "canonical", + Name: "vset-model", + Mode: assertstate.Enforce, + Current: 1, + }) + + snapRevisions := map[string]snap.Revision{ + "pc": snap.R(10), + "pc-kernel-with-kmods": snap.R(11), + "core20": snap.R(12), + "snapd": snap.R(13), + } + + componentRevisions := map[string]snap.Revision{ + "pc-kernel-with-kmods+kmod": snap.R(20), + } + + componentTypes := map[string]snap.ComponentType{ + "pc-kernel-with-kmods+kmod": snap.KernelModulesComponent, + } + + compsToTypes := func(snapName string) map[string]snap.ComponentType { + res := make(map[string]snap.ComponentType) + for _, comps := range snapComponents { + for _, comp := range comps { + res[comp] = componentTypes[naming.NewComponentRef(snapName, comp).String()] + } + } + return res + } + + snapTypes := map[string]snap.Type{ + "pc": snap.TypeGadget, + "pc-kernel-with-kmods": snap.TypeKernel, + "core20": snap.TypeBase, + "snapd": snap.TypeSnapd, + } + + var validationSets []*asserts.ValidationSet + + vsetAssert, err := s.brands.Signing("canonical").Sign(asserts.ValidationSetType, map[string]interface{}{ + "type": "validation-set", + "authority-id": "canonical", + "series": "16", + "account-id": "canonical", + "name": "vset-1", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "revision": snapRevisions["pc"].String(), + "presence": "required", + }, + map[string]interface{}{ + "name": "pc-kernel-with-kmods", + "id": fakeSnapID("pc-kernel-with-kmods"), + "revision": snapRevisions["pc-kernel-with-kmods"].String(), + "presence": "required", + }, + map[string]interface{}{ + "name": "core20", + "id": fakeSnapID("core20"), + "revision": snapRevisions["core20"].String(), + "presence": "required", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "revision": snapRevisions["snapd"].String(), + "presence": "required", + }, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + + validationSets = append(validationSets, vsetAssert.(*asserts.ValidationSet)) + + s.o.TaskRunner().AddHandler("mock-validate", func(task *state.Task, _ *tomb.Tomb) error { + st := task.State() + st.Lock() + defer st.Unlock() + + snapsup, err := snapstate.TaskSnapSetup(task) + c.Assert(err, IsNil) + + s.setupSnapDeclForNameAndID(c, snapsup.SideInfo.RealName, snapsup.SideInfo.SnapID, "canonical") + s.setupSnapRevisionForFileAndID( + c, snapsup.BlobPath(), snapsup.SideInfo.SnapID, "canonical", snapRevisions[snapsup.SideInfo.RealName], + ) + + return nil + }, nil) + + s.o.TaskRunner().AddHandler("mock-download", func(task *state.Task, _ *tomb.Tomb) error { + st := task.State() + st.Lock() + defer st.Unlock() + + snapsup, err := snapstate.TaskSnapSetup(task) + c.Assert(err, IsNil) + var path string + var files [][]string + switch snapsup.Type { + case snap.TypeBase: + path = snaptest.MakeTestSnapWithFiles( + c, + withComponents( + fmt.Sprintf("name: %s\nversion: 1.0\ntype: %s", + snapsup.SideInfo.RealName, + snapsup.Type, + ), + compsToTypes(snapsup.InstanceName()), + ), + nil, + ) + case snap.TypeGadget: + files = [][]string{ + {"meta/gadget.yaml", uc20gadgetYaml}, + } + fallthrough + default: + path = snaptest.MakeTestSnapWithFiles( + c, + withComponents( + fmt.Sprintf("name: %s\nversion: 1.0\nbase: %s\ntype: %s", + snapsup.SideInfo.RealName, + snapsup.Base, + snapsup.Type, + ), + compsToTypes(snapsup.InstanceName()), + ), + files, + ) + } + + err = os.Rename(path, filepath.Join(dirs.SnapBlobDir, fmt.Sprintf("%s_%s.snap", snapsup.SideInfo.RealName, snapsup.Revision().String()))) + c.Assert(err, IsNil) + return nil + }, nil) + + s.o.TaskRunner().AddHandler("mock-validate-component", func(task *state.Task, _ *tomb.Tomb) error { + st := task.State() + st.Lock() + defer st.Unlock() + + compsup, snapsup, err := snapstate.TaskComponentSetup(task) + c.Assert(err, IsNil) + + s.setupSnapResourceRevision( + c, + compsup.BlobPath(snapsup.InstanceName()), + compsup.ComponentName(), + snapsup.SideInfo.SnapID, + "canonical", + componentRevisions[compsup.CompSideInfo.Component.String()], + ) + + s.setupSnapResourcePair( + c, + compsup.ComponentName(), + snapsup.SideInfo.SnapID, + "canonical", + componentRevisions[compsup.CompSideInfo.Component.String()], + snapRevisions[snapsup.SideInfo.RealName], + ) + + s.setupSnapRevisionForFileAndID( + c, snapsup.BlobPath(), snapsup.SideInfo.SnapID, "canonical", snapRevisions[snapsup.SideInfo.RealName], + ) + + return nil + }, nil) + + s.o.TaskRunner().AddHandler("mock-download-component", func(task *state.Task, _ *tomb.Tomb) error { + st := task.State() + st.Lock() + defer st.Unlock() + + compsup, snapsup, err := snapstate.TaskComponentSetup(task) + c.Assert(err, IsNil) + path := snaptest.MakeTestComponent(c, fmt.Sprintf( + "component: %s\nversion: 1.0\ntype: %s\n", + compsup.CompSideInfo.Component.String(), + compsup.CompType, + )) + + err = os.Rename(path, compsup.BlobPath(snapsup.InstanceName())) + c.Assert(err, IsNil) + + return nil + }, nil) + + restore := devicestate.MockSnapstateDownloadComponents(func( + ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, error, + ) { + c.Assert(revOpts.Revision.Unset(), Equals, true) + + si := &snap.SideInfo{ + RealName: name, + Revision: snapRevisions[name], + SnapID: fakeSnapID(name), + } + + snapsup := &snapstate.SnapSetup{ + SideInfo: si, + Base: "core20", + Type: snapTypes[name], + } + + ts := state.NewTaskSet() + var snapsupTask, prev *state.Task + add := func(t *state.Task) { + ts.AddTask(t) + if prev == nil { + t.Set("snap-setup", snapsup) + snapsupTask = t + ts.MarkEdge(t, snapstate.BeginEdge) + } else { + t.WaitFor(prev) + t.Set("snap-setup-task", snapsupTask.ID()) + } + prev = t + } + + var compsupTaskIDs []string + for _, comp := range components { + cref := naming.NewComponentRef(name, comp) + + download := s.state.NewTask("mock-download-component", fmt.Sprintf("Download component %q", cref)) + download.Set("component-setup", &snapstate.ComponentSetup{ + CompSideInfo: &snap.ComponentSideInfo{ + Component: cref, + Revision: componentRevisions[cref.String()], + }, + CompType: componentTypes[cref.String()], + }) + compsupTaskIDs = append(compsupTaskIDs, download.ID()) + add(download) + + validate := s.state.NewTask("mock-validate-component", fmt.Sprintf("Validate component %q", cref)) + validate.Set("component-setup-task", download.ID()) + add(validate) + } + + snapsupTask.Set("component-setup-tasks", compsupTaskIDs) + ts.MarkEdge(prev, snapstate.LastBeforeLocalModificationsEdge) + + return ts, nil + }) + defer restore() + + restore = devicestate.MockSnapstateDownload(func( + ctx context.Context, st *state.State, name string, components []string, dir string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, *snap.Info, error, + ) { + c.Assert(revOpts.Revision.Unset(), Equals, true) + + si := &snap.SideInfo{ + RealName: name, + Revision: snapRevisions[name], + SnapID: fakeSnapID(name), + } + + download := s.state.NewTask("mock-download", fmt.Sprintf("Download %s to track %s", name, revOpts.Channel)) + download.Set("snap-setup", &snapstate.SnapSetup{ + SideInfo: si, + Base: "core20", + Type: snapTypes[name], + }) + + ts := state.NewTaskSet(download) + ts.MarkEdge(download, snapstate.BeginEdge) + prev := download + add := func(t *state.Task) { + t.WaitFor(prev) + t.Set("snap-setup-task", download.ID()) + ts.AddTask(t) + prev = t + } + + validate := s.state.NewTask("mock-validate", fmt.Sprintf("Validate %s", name)) + validate.Set("snap-setup-task", download.ID()) + add(validate) + + var compsupTaskIDs []string + for _, comp := range components { + cref := naming.NewComponentRef(name, comp) + + download := s.state.NewTask("mock-download-component", fmt.Sprintf("Download component %q", cref)) + download.Set("component-setup", &snapstate.ComponentSetup{ + CompSideInfo: &snap.ComponentSideInfo{ + Component: cref, + Revision: componentRevisions[cref.String()], + }, + CompType: componentTypes[cref.String()], + }) + compsupTaskIDs = append(compsupTaskIDs, download.ID()) + add(download) + + validate := s.state.NewTask("mock-validate-component", fmt.Sprintf("Validate component %q", cref)) + validate.Set("component-setup-task", download.ID()) + add(validate) + } + + download.Set("component-setup-tasks", compsupTaskIDs) + ts.MarkEdge(prev, snapstate.LastBeforeLocalModificationsEdge) + + _, info := snaptest.MakeTestSnapInfoWithFiles(c, withComponents(snapYamls[name], compsToTypes(name)), snapFiles[name], si) + opts.PrereqTracker.Add(info) + + return ts, info, nil + }) + defer restore() + + s.state.Set("refresh-privacy-key", "some-privacy-key") + s.mockStandardSnapsModeenvAndBootloaderState(c) + + chg, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ + ValidationSets: validationSets, + TestSystem: true, + MarkDefault: true, + }) + c.Assert(err, IsNil) + + s.validateCreateRecoverySystemChange(c, chg, opts) +} + +func (s *deviceMgrSystemsCreateSuite) validateCreateRecoverySystemChange(c *C, chg *state.Change, opts testCreateRecoverySystemValidationSetsComponentsOpts) { + tsks := chg.Tasks() + + // two per snap, two per comp, create system, finalize system + c.Check(tsks, HasLen, (2*opts.downloadedSnaps)+(2*opts.downloadedComps)+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}) + + var runModeSnaps []string + validateCore20Seed(c, "1234", s.model, s.storeSigning.Trusted, runModeSnaps...) + + 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", + // the setup of this test suite uses a different kernel. this is correct + // because that is the current kernel that is installed on this system + 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(), + }) + + var expectedFilesLog bytes.Buffer + for _, fname := range opts.blobs { + 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", + // the setup of this test suite uses a different kernel. this is correct + // because that is the current kernel that is installed on this system + 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) + + var defaultSystem devicestate.DefaultRecoverySystem + err = s.state.Get("default-recovery-system", &defaultSystem) + c.Assert(err, IsNil) + + c.Assert(defaultSystem.System, Equals, "1234") + c.Assert(defaultSystem.Model, Equals, s.model.Model()) + c.Assert(defaultSystem.BrandID, Equals, s.model.BrandID()) +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsComponentsReuseInstalled(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, map[string]snap.Revision{ + "kmod": snap.R(22), + }) + + devicestate.SetBootOkRan(s.mgr, true) + + s.model = s.makeModelAssertionInState(c, "canonical", "pc-20", map[string]interface{}{ + "architecture": "amd64", + "grade": "dangerous", + "base": "core20", + "revision": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel-with-kmods", + "id": s.ss.AssertedSnapID("pc-kernel-with-kmods"), + "type": "kernel", + "default-channel": "20", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "presence": "required", + }, + }, + }, + map[string]interface{}{ + "name": "pc", + "id": s.ss.AssertedSnapID("pc"), + "type": "gadget", + "default-channel": "20", + }, + map[string]interface{}{ + "name": "core20", + "id": s.ss.AssertedSnapID("core20"), + "type": "base", + }, + map[string]interface{}{ + "name": "snapd", + "id": s.ss.AssertedSnapID("snapd"), + "type": "snapd", + }, + }, + "validation-sets": []interface{}{ + map[string]interface{}{ + "account-id": "canonical", + "name": "vset-model", + "mode": "enforce", + }, + }, + }) + + vset, err := s.brands.Signing("canonical").Sign(asserts.ValidationSetType, map[string]interface{}{ + "type": "validation-set", + "authority-id": "canonical", + "series": "16", + "account-id": "canonical", + "name": "vset-model", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel-with-kmods", + "id": fakeSnapID("pc-kernel-with-kmods"), + "presence": "required", + "revision": "11", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "revision": "22", + "presence": "required", + }, + }, + }, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + + assertstatetest.AddMany(s.state, vset) + assertstate.UpdateValidationSet(s.state, &assertstate.ValidationSetTracking{ + AccountID: "canonical", + Name: "vset-model", + Mode: assertstate.Enforce, + Current: 1, + }) + + s.state.Set("refresh-privacy-key", "some-privacy-key") + s.mockStandardSnapsModeenvAndBootloaderState(c) + + chg, err := devicestate.CreateRecoverySystem(s.state, "1234", devicestate.CreateRecoverySystemOptions{ + TestSystem: true, + MarkDefault: true, + }) + c.Assert(err, IsNil) + c.Assert(chg, NotNil) + tsks := chg.Tasks() + + // create system + finalize system + c.Check(tsks, HasLen, 2) + + create, finalize := tsks[0], tsks[1] + c.Check(create.Summary(), Matches, `Create recovery system with label "1234"`) + c.Check(finalize.Summary(), Matches, `Finalize recovery system with label "1234"`) + + s.state.Unlock() + s.settle(c) + s.state.Lock() + + c.Assert(chg.Err(), IsNil) + c.Assert(create.Status(), Equals, state.WaitStatus) + c.Assert(finalize.Status(), Equals, state.DoStatus) + + // a reboot is expected + c.Check(s.restartRequests, DeepEquals, []restart.RestartType{restart.RestartSystemNow}) + + var runModeSnaps []string + validateCore20Seed(c, "1234", s.model, s.storeSigning.Trusted, runModeSnaps...) + + 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", + // the setup of this test suite uses a different kernel. this is correct + // because that is the current kernel that is installed on this system + 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 + expectedFiles := []string{"snapd_4.snap", "pc-kernel-with-kmods_11.snap", "pc-kernel-with-kmods+kmod_22.comp", "core20_3.snap", "pc_1.snap"} + + var expectedFilesLog bytes.Buffer + for _, fname := range expectedFiles { + 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(create.Status(), Equals, state.DoneStatus) + c.Assert(finalize.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", + // the setup of this test suite uses a different kernel. this is correct + // because that is the current kernel that is installed on this system + 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) + + var defaultSystem devicestate.DefaultRecoverySystem + err = s.state.Get("default-recovery-system", &defaultSystem) + c.Assert(err, IsNil) + + c.Assert(defaultSystem.System, Equals, "1234") + c.Assert(defaultSystem.Model, Equals, s.model.Model()) + c.Assert(defaultSystem.BrandID, Equals, s.model.BrandID()) +} + func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemOnlineWithLocalError(c *C) { devicestate.SetBootOkRan(s.mgr, true) @@ -4317,12 +5239,12 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid return nil, nil, fmt.Errorf("unexpected snap name %q", name) } - c.Check(expectedRev, Equals, revOpts.Revision) + c.Check(revOpts.Revision.Unset(), Equals, true) tDownload := s.state.NewTask("fake-download", fmt.Sprintf("Download %s to track %s", name, revOpts.Channel)) si := &snap.SideInfo{ RealName: name, - Revision: revOpts.Revision, + Revision: expectedRev, SnapID: fakeSnapID(name), } @@ -4356,6 +5278,7 @@ plugs: tDownload.Set("snap-setup", snapsup) _, info := snaptest.MakeTestSnapInfoWithFiles(c, yaml, nil, si) + opts.PrereqTracker.Add(info) tValidate := s.state.NewTask("fake-validate", fmt.Sprintf("Validate %s", name)) tValidate.Set("snap-setup-task", tDownload.ID()) @@ -4683,7 +5606,7 @@ func (s *deviceMgrSystemsCreateSuite) testRemoveRecoverySystem(c *C, mockRetry b } // add an extra file in there so that the snap has a new hash - s.makeSnapInState(c, name, rev, [][]string{{"random-file", "random-content"}}) + s.makeSnapInState(c, name, rev, [][]string{{"random-file", "random-content"}}, nil) } vsetAssert, err := s.brands.Signing("canonical").Sign(asserts.ValidationSetType, map[string]interface{}{ diff --git a/overlord/devicestate/export_test.go b/overlord/devicestate/export_test.go index d2d287e8b34..4e28338d994 100644 --- a/overlord/devicestate/export_test.go +++ b/overlord/devicestate/export_test.go @@ -191,6 +191,12 @@ func MockSnapstateDownload(f func(ctx context.Context, st *state.State, name str return r } +func MockSnapstateDownloadComponents(f func(ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, error)) (restore func()) { + r := testutil.Backup(&snapstateDownloadComponents) + snapstateDownloadComponents = f + return r +} + func EnsureSeeded(m *DeviceManager) error { return m.ensureSeeded() } diff --git a/overlord/devicestate/systems.go b/overlord/devicestate/systems.go index 9926a607da0..80cd1df2b4a 100644 --- a/overlord/devicestate/systems.go +++ b/overlord/devicestate/systems.go @@ -236,7 +236,68 @@ type setupInfoGetter struct { } func (ig *setupInfoGetter) ComponentInfo(st *state.State, cref naming.ComponentRef, snapInfo *snap.Info) (info *snap.ComponentInfo, path string, present bool, err error) { - return nil, "", false, fmt.Errorf("internal error: creating a recovery system with components from recoverySystemSetup not yet supported") + // components will come from one of these places: + // * passed into the task via a list of side infos (these would have + // come from a user posting components via the API) + // * have just been downloaded by a task in setup.ComponentSetupTasks + // * already installed on the system + + // in a remodel scenario, the components may need to be fetched and thus + // their content can be different from what we have already installed, so we + // should first check the download tasks before consulting snapstate + logger.Debugf("requested info for component %q being installed during remodel", cref) + for _, tskID := range ig.setup.ComponentSetupTasks { + taskWithComponentSetup := st.Task(tskID) + compsup, snapsup, err := snapstate.TaskComponentSetup(taskWithComponentSetup) + if err != nil { + return nil, "", false, err + } + if compsup.CompSideInfo.Component != cref { + continue + } + + mountFile := compsup.BlobPath(snapsup.InstanceName()) + + f, err := snapfile.Open(mountFile) + if err != nil { + return nil, "", false, err + } + + info, err = snap.ReadComponentInfoFromContainer(f, snapInfo, compsup.CompSideInfo) + if err != nil { + return nil, "", false, err + } + + return info, mountFile, true, nil + } + + // either a remodel scenario, in which case the component is not among the + // ones being fetched, or just creating a recovery system, in which case we + // use the components that are already installed + + var snapst snapstate.SnapState + if err := snapstate.Get(st, snapInfo.InstanceName(), &snapst); err != nil { + if errors.Is(err, state.ErrNoState) { + return nil, "", false, nil + } + return nil, "", false, err + } + + info, err = snapst.CurrentComponentInfo(cref) + if err != nil { + if errors.Is(err, snapstate.ErrNoCurrent) { + return nil, "", false, nil + } + return nil, "", false, err + } + + cpi := snap.MinimalComponentContainerPlaceInfo( + cref.ComponentName, + info.Revision, + snapInfo.InstanceName(), + ) + + return info, cpi.MountFile(), true, nil } func (ig *setupInfoGetter) SnapInfo(st *state.State, name string) (info *snap.Info, path string, present bool, err error) { diff --git a/overlord/devicestate/systems_test.go b/overlord/devicestate/systems_test.go index df16dd97996..acba0142c51 100644 --- a/overlord/devicestate/systems_test.go +++ b/overlord/devicestate/systems_test.go @@ -93,6 +93,7 @@ var ( } componentYamls = map[string]string{ "pc-kernel-with-kmods+kmod": "component: pc-kernel-with-kmods+kmod\ntype: kernel-modules\nversion: 1.0", + "pc-kernel+kmod": "component: pc-kernel+kmod\ntype: kernel-modules\nversion: 1.0", "other-unasserted+comp": "component: other-unasserted+comp\ntype: standard\nversion: 10.0", "snap-with-components+comp-1": "component: snap-with-components+comp-1\ntype: standard\nversion: 22.0", "snap-with-components+comp-2": "component: snap-with-components+comp-2\ntype: standard\nversion: 33.0", diff --git a/snap/snaptest/snaptest.go b/snap/snaptest/snaptest.go index a9917fd29a4..3355f0dea73 100644 --- a/snap/snaptest/snaptest.go +++ b/snap/snaptest/snaptest.go @@ -274,7 +274,7 @@ func MakeTestComponentWithFiles(c *check.C, componentName, componentYaml string, func MakeTestComponent(c *check.C, compYaml string) string { compInfo, err := snap.InfoFromComponentYaml([]byte(compYaml)) c.Assert(err, check.IsNil) - return MakeTestComponentWithFiles(c, compInfo.FullName()+".comp", compYaml, nil) + return MakeTestComponentWithFiles(c, compInfo.FullName(), compYaml, nil) } func populateContainer(c *check.C, yamlFile, yamlContent string, files [][]string) string { diff --git a/tests/lib/assertions/test-snapd-component-recovery-system-pc-24.json b/tests/lib/assertions/test-snapd-component-recovery-system-pc-24.json new file mode 100644 index 00000000000..506a4a6c019 --- /dev/null +++ b/tests/lib/assertions/test-snapd-component-recovery-system-pc-24.json @@ -0,0 +1,42 @@ +{ + "type": "model", + "authority-id": "developer1", + "series": "16", + "brand-id": "developer1", + "model": "my-model", + "revision": "1", + "architecture": "amd64", + "timestamp": "2024-04-24T00:00:00+00:00", + "grade": "dangerous", + "base": "core24", + "serial-authority": ["generic"], + "snaps": [ + { + "default-channel": "24/edge", + "id": "UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH", + "name": "pc", + "type": "gadget" + }, + { + "default-channel": "24/edge", + "id": "pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza", + "name": "pc-kernel", + "type": "kernel", + "components": { + "wifi-comp": "optional" + } + }, + { + "default-channel": "latest/edge", + "id": "dwTAh7MZZ01zyriOZErqd1JynQLiOGvM", + "name": "core24", + "type": "base" + }, + { + "default-channel": "latest/edge", + "id": "PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4", + "name": "snapd", + "type": "snapd" + } + ] +} diff --git a/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_decl.go b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_decl.go index 110b5b91e02..4330a61ccd6 100644 --- a/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_decl.go +++ b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_decl.go @@ -29,7 +29,7 @@ import ( type cmdNewSnapDeclaration struct { Positional struct { - Snap string `description:"Snap file"` + Snap string `description:"Path to a snap file"` } `positional-args:"yes"` TopDir string `long:"dir" description:"Directory to be used by the store to keep and serve snaps, /asserts is used for assertions"` diff --git a/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_resource_pair.go b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_resource_pair.go new file mode 100644 index 00000000000..3ce11d5421e --- /dev/null +++ b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_resource_pair.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/snapcore/snapd/tests/lib/fakestore/refresh" +) + +type cmdNewSnapResourcePair struct { + Positional struct { + Component string `description:"Path to a component blob file"` + SnapResourcePairJSONPath string `description:"Path to a json encoded snap resource pair revision subset"` + } `positional-args:"yes" required:"yes"` + + TopDir string `long:"dir" description:"Directory to be used by the store to keep and serve snaps, /asserts is used for assertions"` +} + +func (x *cmdNewSnapResourcePair) Execute(args []string) error { + content, err := os.ReadFile(x.Positional.SnapResourcePairJSONPath) + if err != nil { + return err + } + + headers := make(map[string]interface{}) + if err := json.Unmarshal(content, &headers); err != nil { + return err + } + + p, err := refresh.NewSnapResourcePair(x.TopDir, x.Positional.Component, headers) + if err != nil { + return err + } + fmt.Println(p) + return nil +} + +var shortNewSnapResourcePairHelp = "Make a new snap resource pair" + +var longNewSnapResourcePairHelp = ` +Generate a new snap resource pair signed with test keys. Snap ID, snap revision, +and component revision must be provided in the given JSON file. All other +headers are either derived from the component file or optional, but can be +overridden via the given JSON file. +` + +func init() { + parser.AddCommand("new-snap-resource-pair", shortNewSnapResourcePairHelp, longNewSnapResourcePairHelp, + &cmdNewSnapResourcePair{}) +} diff --git a/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_resource_revision.go b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_resource_revision.go new file mode 100644 index 00000000000..890bb9cf928 --- /dev/null +++ b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_resource_revision.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/snapcore/snapd/tests/lib/fakestore/refresh" +) + +type cmdNewSnapResourceRevision struct { + Positional struct { + Component string `description:"Path to a component blob file"` + SnapResourceRevJsonPath string `description:"Path to a json encoded snap resource revision subset"` + } `positional-args:"yes" required:"yes"` + + TopDir string `long:"dir" description:"Directory to be used by the store to keep and serve snaps, /asserts is used for assertions"` +} + +func (x *cmdNewSnapResourceRevision) Execute(args []string) error { + content, err := os.ReadFile(x.Positional.SnapResourceRevJsonPath) + if err != nil { + return err + } + + headers := make(map[string]interface{}) + if err := json.Unmarshal(content, &headers); err != nil { + return err + } + + p, err := refresh.NewSnapResourceRevision(x.TopDir, x.Positional.Component, headers) + if err != nil { + return err + } + fmt.Println(p) + return nil +} + +var shortNewSnapResourceRevisionHelp = "Make a new snap resource revision" + +var longNewSnapResourceRevisionHelp = ` +Generate a new snap resource revision signed with test keys. Snap ID and +revision must be provided in the given JSON file. All other headers are either +derived from the component file or optional, but can be overridden via the given +JSON file. +` + +func init() { + parser.AddCommand("new-snap-resource-revision", shortNewSnapResourceRevisionHelp, longNewSnapResourceRevisionHelp, + &cmdNewSnapResourceRevision{}) +} diff --git a/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_rev.go b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_rev.go index 82bb653e8c0..1085f815742 100644 --- a/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_rev.go +++ b/tests/lib/fakestore/cmd/fakestore/cmd_new_snap_rev.go @@ -29,7 +29,7 @@ import ( type cmdNewSnapRevision struct { Positional struct { - Snap string `description:"Snap file"` + Snap string `description:"Path to a snap file"` } `positional-args:"yes"` TopDir string `long:"dir" description:"Directory to be used by the store to keep and serve snaps, /asserts is used for assertions"` diff --git a/tests/lib/fakestore/refresh/snap_asserts.go b/tests/lib/fakestore/refresh/snap_asserts.go index cc03b6b87f7..21132025905 100644 --- a/tests/lib/fakestore/refresh/snap_asserts.go +++ b/tests/lib/fakestore/refresh/snap_asserts.go @@ -28,6 +28,8 @@ import ( "github.com/snapcore/snapd/asserts" "github.com/snapcore/snapd/asserts/systestkeys" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" ) func snapNameFromPath(snapPath string) string { @@ -68,6 +70,102 @@ func NewSnapRevision(targetDir string, snap string, headers map[string]interface return writeAssert(a, targetDir) } +func NewSnapResourceRevision(targetDir string, compPath string, headers map[string]interface{}) (string, error) { + db, err := newAssertsDB(systestkeys.TestStorePrivKey) + if err != nil { + return "", err + } + digest, size, err := asserts.SnapFileSHA3_384(compPath) + if err != nil { + return "", err + } + + container, err := snapfile.Open(compPath) + if err != nil { + return "", err + } + + ci, err := snap.ReadComponentInfoFromContainer(container, nil, nil) + if err != nil { + return "", err + } + + required := []string{"snap-id", "resource-revision"} + for _, r := range required { + if _, ok := headers[r]; !ok { + return "", fmt.Errorf("missing required header %q", r) + } + } + + defaults := map[string]interface{}{ + "type": "snap-resource-revision", + "authority-id": "testrootorg", + "developer-id": "testrootorg", + "resource-name": ci.Component.ComponentName, + "timestamp": time.Now().Format(time.RFC3339), + "resource-size": fmt.Sprintf("%d", size), + "resource-sha3-384": digest, + } + for k, v := range defaults { + if _, ok := headers[k]; !ok { + headers[k] = v + } + } + headers["authority-id"] = "testrootorg" + headers["snap-sha3-384"] = digest + headers["snap-size"] = fmt.Sprintf("%d", size) + headers["timestamp"] = time.Now().Format(time.RFC3339) + + a, err := db.Sign(asserts.SnapResourceRevisionType, headers, nil, systestkeys.TestStoreKeyID) + if err != nil { + return "", err + } + return writeAssert(a, targetDir) +} + +func NewSnapResourcePair(targetDir string, compPath string, headers map[string]interface{}) (string, error) { + db, err := newAssertsDB(systestkeys.TestStorePrivKey) + if err != nil { + return "", err + } + + container, err := snapfile.Open(compPath) + if err != nil { + return "", err + } + + ci, err := snap.ReadComponentInfoFromContainer(container, nil, nil) + if err != nil { + return "", err + } + + required := []string{"snap-id", "resource-revision", "snap-revision"} + for _, r := range required { + if _, ok := headers[r]; !ok { + return "", fmt.Errorf("missing required header %q", r) + } + } + + defaults := map[string]interface{}{ + "type": "snap-resource-pair", + "authority-id": "testrootorg", + "developer-id": "testrootorg", + "resource-name": ci.Component.ComponentName, + "timestamp": time.Now().Format(time.RFC3339), + } + for k, v := range defaults { + if _, ok := headers[k]; !ok { + headers[k] = v + } + } + + a, err := db.Sign(asserts.SnapResourcePairType, headers, nil, systestkeys.TestStoreKeyID) + if err != nil { + return "", err + } + return writeAssert(a, targetDir) +} + func NewSnapDeclaration(targetDir string, snap string, headers map[string]interface{}) (string, error) { db, err := newAssertsDB(systestkeys.TestStorePrivKey) if err != nil { diff --git a/tests/lib/fakestore/store/store.go b/tests/lib/fakestore/store/store.go index dcd04d74503..5f6424b75e7 100644 --- a/tests/lib/fakestore/store/store.go +++ b/tests/lib/fakestore/store/store.go @@ -30,6 +30,7 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" "regexp" "strconv" @@ -197,7 +198,7 @@ type essentialInfo struct { Base string } -func snapEssentialInfo(fn, snapID string, bs asserts.Backstore, cs *ChannelRepository) (*essentialInfo, error) { +func snapEssentialInfo(fn, snapID string, bs asserts.Backstore) (*essentialInfo, error) { f, err := snapfile.Open(fn) if err != nil { return nil, fmt.Errorf("cannot read: %v: %v", fn, err) @@ -250,6 +251,79 @@ func snapEssentialInfo(fn, snapID string, bs asserts.Backstore, cs *ChannelRepos }, nil } +func addComponentBlobToRevisionSet(snaps map[string]*revisionSet, snapIDs map[string]string, fn string, bs asserts.Backstore) error { + f, err := snapfile.Open(fn) + if err != nil { + return fmt.Errorf("cannot read: %v: %v", fn, err) + } + + info, err := snap.ReadComponentInfoFromContainer(f, nil, nil) + if err != nil { + return fmt.Errorf("cannot get info for: %v: %v", fn, err) + } + + compName := info.Component.ComponentName + snapName := info.Component.SnapName + + digest, _, err := asserts.SnapFileSHA3_384(fn) + if err != nil { + return fmt.Errorf("cannot get digest for: %v: %v", fn, err) + } + + set, ok := snaps[snapName] + if !ok { + return fmt.Errorf("cannot find snap %q for component: %q", snapName, info.Component) + } + + snapID, ok := snapIDs[snapName] + if !ok { + return fmt.Errorf("cannot find snap id for snap %q", snapName) + } + + pk, err := asserts.PrimaryKeyFromHeaders(asserts.SnapResourceRevisionType, map[string]string{ + "snap-id": snapID, + "resource-name": compName, + "resource-sha3-384": digest, + }) + if err != nil { + return err + } + + a, err := bs.Get(asserts.SnapResourceRevisionType, pk, asserts.SnapResourceRevisionType.MaxSupportedFormat()) + if err != nil { + return err + } + compRev := snap.R(a.(*asserts.SnapResourceRevision).ResourceRevision()) + + for snapRev := range set.revisions { + pk, err := asserts.PrimaryKeyFromHeaders(asserts.SnapResourcePairType, map[string]string{ + "resource-name": compName, + "snap-id": snapID, + "resource-revision": compRev.String(), + "snap-revision": snapRev.String(), + }) + if err != nil { + return err + } + + _, err = bs.Get(asserts.SnapResourcePairType, pk, asserts.SnapResourcePairType.MaxSupportedFormat()) + if err != nil { + // no pair assertion for this snap revision, so this one isn't + // associated with this snap revision + if errors.Is(err, &asserts.NotFoundError{}) { + continue + } + return err + } + + if err := set.addComponent(compName, compRev, fn, snapRev); err != nil { + return err + } + } + + return nil +} + type detailsReplyJSON struct { Architectures []string `json:"architecture"` SnapID string `json:"snap_id"` @@ -372,9 +446,9 @@ func (s *Store) detailsEndpoint(w http.ResponseWriter, req *http.Request) { return } - fn := set.getLatest() + sn := set.getLatest() - essInfo, err := snapEssentialInfo(fn, "", bs, s.channelRepository) + essInfo, err := snapEssentialInfo(sn.path, "", bs) if err != nil { http.Error(w, err.Error(), 400) return @@ -386,8 +460,8 @@ func (s *Store) detailsEndpoint(w http.ResponseWriter, req *http.Request) { PackageName: essInfo.Name, Developer: essInfo.DevelName, DeveloperID: essInfo.DeveloperID, - AnonDownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(fn)), - DownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(fn)), + AnonDownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(sn.path)), + DownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(sn.path)), Version: essInfo.Version, Revision: essInfo.Revision, DownloadDigest: hexify(essInfo.Digest), @@ -407,37 +481,58 @@ func (s *Store) detailsEndpoint(w http.ResponseWriter, req *http.Request) { } type revisionSet struct { - latest snap.Revision - containers map[snap.Revision]string + latest snap.Revision + revisions map[snap.Revision]availableSnap +} + +type availableSnap struct { + path string + components map[string]availableComponent +} + +type availableComponent struct { + path string + revision snap.Revision } -func (rs *revisionSet) get(rev snap.Revision) (string, bool) { +func (rs *revisionSet) get(rev snap.Revision) (availableSnap, bool) { if rev.Unset() { rev = rs.latest } - path, ok := rs.containers[rev] - return path, ok + sn, ok := rs.revisions[rev] + return sn, ok } -func (rs *revisionSet) getLatest() string { - path, ok := rs.containers[rs.latest] +func (rs *revisionSet) getLatest() availableSnap { + sn, ok := rs.revisions[rs.latest] if !ok { panic("internal error: revision set should always contain latest revision") } - return path + return sn } func (rs *revisionSet) add(rev snap.Revision, path string) { - if rs.containers == nil { - rs.containers = make(map[snap.Revision]string) + if rs.revisions == nil { + rs.revisions = make(map[snap.Revision]availableSnap) } if rs.latest.N < rev.N { rs.latest = rev } - rs.containers[rev] = path + rs.revisions[rev] = availableSnap{path: path, components: make(map[string]availableComponent)} +} + +func (rs *revisionSet) addComponent(name string, compRev snap.Revision, path string, snapRev snap.Revision) error { + sn, ok := rs.revisions[snapRev] + if !ok { + return fmt.Errorf("cannot find snap revision %q", snapRev) + } + + sn.components[name] = availableComponent{path: path, revision: compRev} + + return nil } func (s *Store) collectSnaps(bs asserts.Backstore) (map[string]*revisionSet, error) { @@ -450,11 +545,12 @@ func (s *Store) collectSnaps(bs asserts.Backstore) (map[string]*revisionSet, err defer restoreSanitize() snaps := make(map[string]*revisionSet) + snapNamesToID := make(map[string]string, len(snapFns)) for _, fn := range snapFns { - // we only care about the revision here, so we can get away without - // setting the id + // if the snap is asserted, then the returned info will contain the ID + // taken from the database const snapID = "" - info, err := snapEssentialInfo(fn, snapID, bs, s.channelRepository) + info, err := snapEssentialInfo(fn, snapID, bs) if err != nil { return nil, err } @@ -469,6 +565,7 @@ func (s *Store) collectSnaps(bs asserts.Backstore) (map[string]*revisionSet, err if err != nil { return nil, err } + for _, channel := range channels { compositeName := fmt.Sprintf("%s|%s", info.Name, channel) if _, ok := snaps[compositeName]; !ok { @@ -477,9 +574,24 @@ func (s *Store) collectSnaps(bs asserts.Backstore) (map[string]*revisionSet, err snaps[compositeName].add(snap.R(info.Revision), fn) } + if info.SnapID != "" { + snapNamesToID[info.Name] = info.SnapID + } + logger.Debugf("found snap %q (revision %d) at %v", info.Name, info.Revision, fn) } + compFns, err := filepath.Glob(filepath.Join(s.blobDir, "*.comp")) + if err != nil { + return nil, err + } + + for _, fn := range compFns { + if err := addComponentBlobToRevisionSet(snaps, snapNamesToID, fn, bs); err != nil { + return nil, err + } + } + return snaps, err } @@ -565,9 +677,9 @@ func (s *Store) bulkEndpoint(w http.ResponseWriter, req *http.Request) { continue } - fn := set.getLatest() + sn := set.getLatest() - essInfo, err := snapEssentialInfo(fn, pkg.SnapID, bs, s.channelRepository) + essInfo, err := snapEssentialInfo(sn.path, pkg.SnapID, bs) if err != nil { http.Error(w, err.Error(), 400) return @@ -579,8 +691,8 @@ func (s *Store) bulkEndpoint(w http.ResponseWriter, req *http.Request) { PackageName: essInfo.Name, Developer: essInfo.DevelName, DeveloperID: essInfo.DeveloperID, - DownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(fn)), - AnonDownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(fn)), + DownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(sn.path)), + AnonDownloadURL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(sn.path)), Version: essInfo.Version, Revision: essInfo.Revision, DownloadDigest: hexify(essInfo.Digest), @@ -681,15 +793,26 @@ type detailsResultV2 struct { ID string `json:"id"` Username string `json:"username"` } `json:"publisher"` - Download struct { - URL string `json:"url"` - Sha3_384 string `json:"sha3-384"` - Size uint64 `json:"size"` - } `json:"download"` - Version string `json:"version"` - Revision int `json:"revision"` - Confinement string `json:"confinement"` - Type string `json:"type"` + Download downloadInfo `json:"download"` + Version string `json:"version"` + Revision int `json:"revision"` + Confinement string `json:"confinement"` + Type string `json:"type"` + Resources []snapResourceResult `json:"resources,omitempty"` +} + +type downloadInfo struct { + URL string `json:"url"` + Sha3_384 string `json:"sha3-384"` + Size uint64 `json:"size"` +} + +type snapResourceResult struct { + Download downloadInfo `json:"download"` + Type string `json:"type"` + Name string `json:"name"` + Revision int `json:"revision"` + Version string `json:"version"` } func (s *Store) snapActionEndpoint(w http.ResponseWriter, req *http.Request) { @@ -774,39 +897,78 @@ func (s *Store) snapActionEndpoint(w http.ResponseWriter, req *http.Request) { continue } - fn, ok := set.get(snap.R(a.Revision)) + sn, ok := set.get(snap.R(a.Revision)) if !ok { // TODO: this should send back some error? continue } - essInfo, err := snapEssentialInfo(fn, snapID, bs, s.channelRepository) + essInfo, err := snapEssentialInfo(sn.path, snapID, bs) if err != nil { http.Error(w, err.Error(), 400) return } + resources := make([]snapResourceResult, 0, len(sn.components)) + for compName, comp := range sn.components { + f, err := snapfile.Open(path.Join(comp.path)) + if err != nil { + http.Error(w, fmt.Sprintf("cannot read: %v: %v", compName, err), 400) + return + } + + digest, size, err := asserts.SnapFileSHA3_384(comp.path) + if err != nil { + http.Error(w, fmt.Sprintf("cannot get digest for: %v: %v", compName, err), 400) + return + } + + compInfo, err := snap.ReadComponentInfoFromContainer(f, nil, nil) + if err != nil { + http.Error(w, fmt.Sprintf("cannot get info for: %v: %v", compName, err), 400) + return + } + + resources = append(resources, snapResourceResult{ + Name: compName, + Revision: comp.revision.N, + Type: fmt.Sprintf("component/%s", compInfo.Type), + Version: compInfo.Version(essInfo.Version), + Download: downloadInfo{ + URL: fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(comp.path)), + Sha3_384: hexify(digest), + Size: size, + }, + }) + } + + details := detailsResultV2{ + Architectures: []string{"all"}, + SnapID: essInfo.SnapID, + Name: essInfo.Name, + Version: essInfo.Version, + Revision: essInfo.Revision, + Confinement: essInfo.Confinement, + Type: essInfo.Type, + Base: essInfo.Base, + } + if len(resources) > 0 { + details.Resources = resources + } + res := &snapActionResult{ Result: a.Action, InstanceKey: a.InstanceKey, SnapID: essInfo.SnapID, Name: essInfo.Name, - Snap: detailsResultV2{ - Architectures: []string{"all"}, - SnapID: essInfo.SnapID, - Name: essInfo.Name, - Version: essInfo.Version, - Revision: essInfo.Revision, - Confinement: essInfo.Confinement, - Type: essInfo.Type, - Base: essInfo.Base, - }, + Snap: details, } + logger.Debugf("requested snap %q revision %d", essInfo.Name, a.Revision) res.Snap.Publisher.ID = essInfo.DeveloperID res.Snap.Publisher.Username = essInfo.DevelName - res.Snap.Download.URL = fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(fn)) + res.Snap.Download.URL = fmt.Sprintf("%s/download/%s", s.RealURL(req), filepath.Base(sn.path)) res.Snap.Download.Sha3_384 = hexify(essInfo.Digest) res.Snap.Download.Size = essInfo.Size replyData.Results = append(replyData.Results, res) @@ -971,14 +1133,12 @@ func (s *Store) nonceEndpoint(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"nonce": "blah"}`)) - return } func (s *Store) sessionEndpoint(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"macaroon": "blahblah"}`)) - return } type ChannelRepository struct { @@ -987,20 +1147,19 @@ type ChannelRepository struct { func (cr *ChannelRepository) findSnapChannels(snapDigest string) ([]string, error) { dataPath := filepath.Join(cr.rootDir, snapDigest) - fd, err := os.Open(dataPath) + f, err := os.Open(dataPath) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil, nil - } else { - return nil, err } - } else { - defer fd.Close() - sc := bufio.NewScanner(fd) - var lines []string - for sc.Scan() { - lines = append(lines, sc.Text()) - } - return lines, nil + return nil, err + } + defer f.Close() + + sc := bufio.NewScanner(f) + var lines []string + for sc.Scan() { + lines = append(lines, sc.Text()) } + return lines, nil } diff --git a/tests/lib/fakestore/store/store_test.go b/tests/lib/fakestore/store/store_test.go index c173e4ce88c..1e1357391ba 100644 --- a/tests/lib/fakestore/store/store_test.go +++ b/tests/lib/fakestore/store/store_test.go @@ -358,6 +358,14 @@ func (s *storeTestSuite) makeTestSnap(c *C, snapYamlContent string) string { return dst } +func (s *storeTestSuite) makeTestComponent(c *C, yaml string) string { + fn := snaptest.MakeTestComponent(c, yaml) + dst := filepath.Join(s.store.blobDir, filepath.Base(fn)) + err := osutil.CopyFile(fn, dst, 0) + c.Assert(err, IsNil) + return dst +} + var ( tSnapDecl = template.Must(template.New("snap-decl").Parse(`type: snap-declaration authority-id: testrootorg @@ -391,6 +399,32 @@ validation: unproven timestamp: 2016-08-19T19:19:19Z sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij +AXNpZw= +`)) + tResourceRevision = template.Must(template.New("resource-revision").Parse(`type: snap-resource-revision +authority-id: testrootorg +snap-id: {{.SnapID}} +resource-name: {{.Name}} +resource-size: {{.Size}} +resource-sha3-384: {{.Digest}} +resource-revision: {{.Revision}} +developer-id: {{.DeveloperID}} +snap-name: {{.Name}} +timestamp: 2016-08-19T19:19:19Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw= +`)) + tResourcePair = template.Must(template.New("resource-pair").Parse(`type: snap-resource-pair +authority-id: testrootorg +snap-id: {{.SnapID}} +resource-name: {{.Name}} +resource-revision: {{.Revision}} +snap-revision: {{.SnapRevision}} +developer-id: {{.DeveloperID}} +timestamp: 2016-08-19T19:19:19Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + AXNpZw= `)) ) @@ -436,6 +470,41 @@ func (s *storeTestSuite) addToChannel(c *C, snapFn, channel string) { fmt.Fprintf(f, "%s\n", channel) } +func (s *storeTestSuite) makeComponentAssertions(c *C, fn, name, snapID, develID string, compRev, snapRev int) { + type essentialComponentInfo struct { + Name string + SnapID string + DeveloperID string + Revision int + SnapRevision int + Digest string + Size uint64 + } + + digest, size, err := asserts.SnapFileSHA3_384(fn) + c.Assert(err, IsNil) + + info := essentialComponentInfo{ + Name: name, + SnapID: snapID, + DeveloperID: develID, + Revision: compRev, + SnapRevision: snapRev, + Digest: digest, + Size: size, + } + + f, err := os.OpenFile(filepath.Join(s.store.assertDir, fmt.Sprintf("%s+%s.fake.snap-resource-revison", snapID, name)), os.O_CREATE|os.O_WRONLY, 0644) + c.Assert(err, IsNil) + err = tResourceRevision.Execute(f, info) + c.Assert(err, IsNil) + + f, err = os.OpenFile(filepath.Join(s.store.assertDir, fmt.Sprintf("%s+%s+%d.fake.snap-resource-pair", snapID, name, snapRev)), os.O_CREATE|os.O_WRONLY, 0644) + c.Assert(err, IsNil) + err = tResourcePair.Execute(f, info) + c.Assert(err, IsNil) +} + func (s *storeTestSuite) TestMakeTestSnap(c *C) { snapFn := s.makeTestSnap(c, "name: foo\nversion: 1") c.Assert(osutil.FileExists(snapFn), Equals, true) @@ -452,6 +521,12 @@ func (s *storeTestSuite) TestCollectSnaps(c *C) { fn = s.makeTestSnap(c, "name: bar\nversion: 3") s.makeAssertions(c, fn, "bar", snaptest.AssertedSnapID("bar"), "devel", "devel-id", 7) + fn = s.makeTestComponent(c, "component: foo+comp1\nversion: 4\ntype: standard") + + // same component is shared across two snap revisions + s.makeComponentAssertions(c, fn, "comp1", snaptest.AssertedSnapID("foo"), "devel-id", 8, 5) + s.makeComponentAssertions(c, fn, "comp1", snaptest.AssertedSnapID("foo"), "devel-id", 8, 6) + bs, err := s.store.collectAssertions() c.Assert(err, IsNil) @@ -460,15 +535,34 @@ func (s *storeTestSuite) TestCollectSnaps(c *C) { c.Assert(snaps, DeepEquals, map[string]*revisionSet{ "foo": { latest: snap.R(6), - containers: map[snap.Revision]string{ - snap.R(5): filepath.Join(s.store.blobDir, "foo_1_all.snap"), - snap.R(6): filepath.Join(s.store.blobDir, "foo_2_all.snap"), + revisions: map[snap.Revision]availableSnap{ + snap.R(5): { + path: filepath.Join(s.store.blobDir, "foo_1_all.snap"), + components: map[string]availableComponent{ + "comp1": { + path: filepath.Join(s.store.blobDir, "foo+comp1.comp"), + revision: snap.R(8), + }, + }, + }, + snap.R(6): { + path: filepath.Join(s.store.blobDir, "foo_2_all.snap"), + components: map[string]availableComponent{ + "comp1": { + path: filepath.Join(s.store.blobDir, "foo+comp1.comp"), + revision: snap.R(8), + }, + }, + }, }, }, "bar": { latest: snap.R(7), - containers: map[snap.Revision]string{ - snap.R(7): filepath.Join(s.store.blobDir, "bar_3_all.snap"), + revisions: map[snap.Revision]availableSnap{ + snap.R(7): { + path: filepath.Join(s.store.blobDir, "bar_3_all.snap"), + components: make(map[string]availableComponent), + }, }, }, }) @@ -858,6 +952,104 @@ func (s *storeTestSuite) TestSnapActionEndpointAssertedWithRevision(c *C) { request(snap.R(6), "2", latestFn) } +func (s *storeTestSuite) TestSnapActionEndpointAssertedWithComponents(c *C) { + snapWithoutComp := s.makeTestSnap(c, "name: test-snapd-tools\nversion: 1") + s.makeAssertions(c, snapWithoutComp, "test-snapd-tools", "eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", "canonical", "canonical", 5) + + snapWithcomp := s.makeTestSnap(c, "name: test-snapd-tools\nversion: 2") + s.makeAssertions(c, snapWithcomp, "test-snapd-tools", "eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", "canonical", "canonical", 6) + + componentFn := s.makeTestComponent(c, "component: test-snapd-tools+comp1\nversion: 4\ntype: standard") + s.makeComponentAssertions(c, componentFn, "comp1", "eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", "canonical", 8, 6) + + compDigest, compSize, err := asserts.SnapFileSHA3_384(componentFn) + c.Assert(err, IsNil) + + type availableComponent struct { + path string + digest string + size uint64 + revision snap.Revision + version string + } + + request := func(rev snap.Revision, version string, path string, comps map[string]availableComponent) { + post := fmt.Sprintf(`{ + "context": [{"instance-key":"eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw","snap-id":"eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw","tracking-channel":"stable","revision":1}], + "actions": [{"action":"refresh","instance-key":"eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw","snap-id":"eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", "revision":%d}] + }`, rev.N) + + resp, err := s.StorePostJSON("/v2/snaps/refresh", []byte(post)) + c.Assert(err, IsNil) + defer resp.Body.Close() + + c.Assert(resp.StatusCode, Equals, 200) + var body struct { + Results []map[string]interface{} + } + c.Assert(json.NewDecoder(resp.Body).Decode(&body), IsNil) + c.Check(body.Results, HasLen, 1) + sha3_384, size := getSha(path) + + payload := map[string]interface{}{ + "result": "refresh", + "instance-key": "eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", + "snap-id": "eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", + "name": "test-snapd-tools", + "snap": map[string]interface{}{ + "architectures": []interface{}{"all"}, + "snap-id": "eFe8BTR5L5V9F7yHeMAPxkEr2NdUXMtw", + "name": "test-snapd-tools", + "publisher": map[string]interface{}{ + "username": "canonical", + "id": "canonical", + }, + "download": map[string]interface{}{ + "url": s.store.URL() + "/download/" + filepath.Base(path), + "sha3-384": sha3_384, + "size": float64(size), + }, + "version": version, + "revision": float64(rev.N), + "confinement": "strict", + "type": "app", + }, + } + + var resources []interface{} + for name, comp := range comps { + resources = append(resources, map[string]interface{}{ + "download": map[string]interface{}{ + "url": s.store.URL() + "/download/" + filepath.Base(comp.path), + "sha3-384": comp.digest, + "size": float64(comp.size), + }, + "type": "component/standard", + "name": name, + "revision": float64(comp.revision.N), + "version": comp.version, + }) + } + + if len(resources) > 0 { + payload["snap"].(map[string]interface{})["resources"] = resources + } + + c.Check(body.Results[0], DeepEquals, payload) + } + + request(snap.R(5), "1", snapWithoutComp, map[string]availableComponent{}) + request(snap.R(6), "2", snapWithcomp, map[string]availableComponent{ + "comp1": { + path: componentFn, + digest: hexify(compDigest), + size: compSize, + revision: snap.R(8), + version: "4", + }, + }) +} + func (s *storeTestSuite) TestSnapActionEndpointWithAssertions(c *C) { snapFn := s.makeTestSnap(c, "name: foo\nversion: 10") s.makeAssertions(c, snapFn, "foo", "xidididididididididididididididid", "foo-devel", "foo-devel-id", 99) diff --git a/tests/lib/nested.sh b/tests/lib/nested.sh index 5e70cfc2f8b..52d461a5138 100755 --- a/tests/lib/nested.sh +++ b/tests/lib/nested.sh @@ -15,6 +15,7 @@ : "${NESTED_CUSTOM_AUTO_IMPORT_ASSERTION:=}" : "${NESTED_FAKESTORE_BLOB_DIR:=${NESTED_WORK_DIR}/fakestore/blobs}" : "${NESTED_SIGN_SNAPS_FAKESTORE:=false}" +: "${NESTED_REPACK_FOR_FAKESTORE:=false}" : "${NESTED_FAKESTORE_SNAP_DECL_PC_GADGET:=}" : "${NESTED_UBUNTU_IMAGE_SNAPPY_FORCE_SAS_URL:=}" : "${NESTED_UBUNTU_IMAGE_PRESEED_KEY:=}" @@ -771,6 +772,14 @@ EOF "$TESTSLIB"/manip_ubuntu_seed.py pc-gadget/meta/gadget.yaml "$NESTED_UBUNTU_SEED_SIZE" fi + if [ "$NESTED_REPACK_FOR_FAKESTORE" = "true" ]; then + cat > pc-gadget/meta/hooks/prepare-device << EOF +#!/bin/sh +snapctl set device-service.url=http://10.0.2.2:11029 +EOF + chmod +x pc-gadget/meta/hooks/prepare-device + fi + # pack the gadget snap pack pc-gadget/ "$NESTED_ASSETS_DIR" diff --git a/tests/lib/prepare.sh b/tests/lib/prepare.sh index 142d5ada4c4..3f284c1c7e0 100755 --- a/tests/lib/prepare.sh +++ b/tests/lib/prepare.sh @@ -696,6 +696,13 @@ StandardOutput=journal+console StandardError=journal+console EOF + if [ "$NESTED_REPACK_FOR_FAKESTORE" = "true" ]; then + cat < "$UNPACK_DIR"/etc/systemd/system/snapd.service.d/store.conf +[Service] +Environment=SNAPPY_FORCE_API_URL=http://10.0.2.2:11028 +EOF + fi + cp "${SPREAD_PATH}"/data/completion/bash/complete.sh "${UNPACK_DIR}"/usr/lib/snapd/complete.sh snap pack --filename="$TARGET" "$UNPACK_DIR" diff --git a/tests/lib/tools/build_kernel_with_comps.sh b/tests/lib/tools/build_kernel_with_comps.sh index 5d997554d32..79106f71fa8 100755 --- a/tests/lib/tools/build_kernel_with_comps.sh +++ b/tests/lib/tools/build_kernel_with_comps.sh @@ -11,10 +11,14 @@ set -uxe build_kernel_with_comp() { mod_name=$1 comp_name=$2 + kernel_snap_file=$3 - VERSION="$(tests.nested show version)" - snap download --channel="$VERSION"/beta --basename=pc-kernel pc-kernel - unsquashfs -d kernel pc-kernel.snap + if [ -z "${kernel_snap_file}" ]; then + VERSION="$(tests.nested show version)" + snap download --channel="$VERSION"/beta --basename=pc-kernel pc-kernel + kernel_snap_file="pc-kernel.snap" + fi + unsquashfs -d kernel "${kernel_snap_file}" kern_ver=$(find kernel/modules/* -maxdepth 0 -printf "%f\n") comp_ko_dir=$comp_name/modules/"$kern_ver"/kmod/ mkdir -p "$comp_ko_dir" @@ -39,10 +43,10 @@ EOF ln -s ../modules kernel/lib/modules depmod -b kernel/ "$kern_ver" rm -rf kernel/lib - rm pc-kernel.snap + rm "${kernel_snap_file}" # append component meta-information printf 'components:\n %s:\n type: kernel-modules\n' "$comp_name" >> kernel/meta/snap.yaml - snap pack --filename=pc-kernel.snap kernel + snap pack --filename="${kernel_snap_file}" kernel } build_kernel_with_comp "$@" diff --git a/tests/lib/tools/store-state b/tests/lib/tools/store-state index 05c077e76b7..66aca2c6f69 100755 --- a/tests/lib/tools/store-state +++ b/tests/lib/tools/store-state @@ -10,6 +10,7 @@ show_help() { echo " store-state setup-staging-store" echo " store-state teardown-staging-store" echo " store-state make-snap-installable [--noack ] [--extra-decl-json FILE] [SNAP_ID]" + echo " store-state make-component-installable --snap-id --component-revision --snap-revision [--noack ] " echo " store-state init-fake-refreshes " echo " store-state add-to-channel " } @@ -124,6 +125,85 @@ EOF rm -f /tmp/snap-decl.json /tmp/snap-rev.json } + +make_component_installable(){ + local ack=true + local component_rev=""; + local snap_rev=""; + local snap_id=""; + while [ $# -gt 0 ]; do + case "$1" in + (--component-revision) + component_rev="$2" + shift 2 + ;; + (--snap-id) + snap_id="$2" + shift 2 + ;; + (--snap-revision) + snap_rev="$2" + shift 2 + ;; + (--noack) + ack=false + shift + ;; + (*) + break + ;; + esac + done + + if [ -z "${snap_id}" ]; then + echo "snap-id must be provided" + return 1 + fi + + if [ -z "${component_rev}" ]; then + echo "component-revision must be provided" + return 1 + fi + + if [ -z "${snap_rev}" ]; then + echo "snap-revision must be provided" + return 1 + fi + + local dir="$1" + local path="$2" + + work=$(mktemp -d) + + cat > "/${work}/snap-resource-revision.json" << EOF +{ + "snap-id": "${snap_id}", + "publisher-id": "developer1", + "resource-revision": "${component_rev}" +} +EOF + + cat > "/${work}/snap-resource-pair.json" << EOF +{ + "snap-id": "${snap_id}", + "publisher-id": "developer1", + "resource-revision": "${component_rev}", + "snap-revision": "${snap_rev}" +} +EOF + + resource_rev_assert=$(fakestore new-snap-resource-revision --dir "${dir}" "${path}" "/${work}/snap-resource-revision.json") + resource_pair_assert=$(fakestore new-snap-resource-pair --dir "${dir}" "${path}" "/${work}/snap-resource-pair.json") + + if [ "${ack}" = "true" ]; then + snap ack "${resource_rev_assert}" + snap ack "${resource_pair_assert}" + fi + + cp -av "${path}" "${dir}/" + rm -rf "${work}" +} + setup_fake_store(){ local top_dir=$1 diff --git a/tests/nested/manual/component-recovery-system/task.yaml b/tests/nested/manual/component-recovery-system/task.yaml new file mode 100644 index 00000000000..86b876bb9fe --- /dev/null +++ b/tests/nested/manual/component-recovery-system/task.yaml @@ -0,0 +1,158 @@ +summary: create a recovery system with a kernel module component and reboot into it + +details: | + This test creates a recovery system with a kernel module component and + validates that the newly created system can be rebooted into. + +systems: [ubuntu-24.04-64] + +environment: + MODEL_JSON: $TESTSLIB/assertions/test-snapd-component-recovery-system-pc-24.json + NESTED_ENABLE_TPM: true + NESTED_ENABLE_SECURE_BOOT: true + NESTED_BUILD_SNAPD_FROM_CURRENT: true + NESTED_REPACK_GADGET_SNAP: true + NESTED_REPACK_KERNEL_SNAP: true + NESTED_REPACK_BASE_SNAP: true + NESTED_REPACK_FOR_FAKESTORE: true + NESTED_FAKESTORE_BLOB_DIR: $(pwd)/fake-store-blobdir + NESTED_SIGN_SNAPS_FAKESTORE: true + NESTED_UBUNTU_IMAGE_SNAPPY_FORCE_SAS_URL: http://localhost:11028 + +prepare: | + if [ "${TRUST_TEST_KEYS}" = "false" ]; then + echo "This test needs test keys to be trusted" + exit + fi + + snap install test-snapd-swtpm --edge + + "${TESTSTOOLS}/store-state" setup-fake-store "${NESTED_FAKESTORE_BLOB_DIR}" + + gendeveloper1 sign-model < "${MODEL_JSON}" > model.assert + + cp "${TESTSLIB}/assertions/testrootorg-store.account-key" "${NESTED_FAKESTORE_BLOB_DIR}/asserts" + cp "${TESTSLIB}/assertions/developer1.account" "${NESTED_FAKESTORE_BLOB_DIR}/asserts" + cp "${TESTSLIB}/assertions/developer1.account-key" "${NESTED_FAKESTORE_BLOB_DIR}/asserts" + cp model.assert "${NESTED_FAKESTORE_BLOB_DIR}/asserts" + + tests.nested prepare-essential-snaps + + export SNAPPY_FORCE_API_URL="${NESTED_UBUNTU_IMAGE_SNAPPY_FORCE_SAS_URL}" + ubuntu-image snap --channel edge --image-size 10G ./model.assert + + image_dir=$(tests.nested get images-path) + image_name=$(tests.nested get image-name core) + cp ./pc.img "${image_dir}/${image_name}" + tests.nested configure-default-user + + # run the fake device service too, so that the device can be initialised + systemd-run --collect --unit fakedevicesvc fakedevicesvc localhost:11029 + + tests.nested build-image core + tests.nested create-vm core + + #shellcheck source=tests/lib/core-config.sh + . "$TESTSLIB"/core-config.sh + wait_for_first_boot_change + + remote.exec 'sudo systemctl stop snapd snapd.socket' + + remote.exec 'sudo cat /var/lib/snapd/state.json' | gojq '.data.auth.device."session-macaroon"="fake-session"' > state.json + remote.push state.json + remote.exec 'sudo mv state.json /var/lib/snapd/state.json' + remote.exec 'sudo systemctl start snapd snapd.socket' + +restore: | + systemctl stop fakedevicesvc + "${TESTSTOOLS}/store-state" teardown-fake-store "${NESTED_FAKESTORE_BLOB_DIR}" + +execute: | + function post_json_data() { + route=$1 + template=$2 + shift 2 + + # shellcheck disable=SC2059 + response=$(printf "${template}" "$@" | remote.exec "sudo snap debug api -X POST -H 'Content-Type: application/json' ${route}") + if ! gojq -e .change <<< "${response}"; then + echo "could not get change id from response: ${response}" + false + fi + } + + unsquashfs "${NESTED_FAKESTORE_BLOB_DIR}/pc-kernel.snap" + sed -i -e '/^version/ s/$/-with-comps/' squashfs-root/meta/snap.yaml + snap pack --filename=pc-kernel-with-comps.snap ./squashfs-root + "${TESTSTOOLS}"/build_kernel_with_comps.sh mac80211_hwsim wifi-comp pc-kernel-with-comps.snap + + kernel_id="pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza" + + # bump the available kernel version in the fake store + "${TESTSTOOLS}"/store-state make-snap-installable --noack \ + --revision 2 \ + "${NESTED_FAKESTORE_BLOB_DIR}" \ + ./pc-kernel-with-comps.snap \ + "${kernel_id}" + + "${TESTSTOOLS}"/store-state make-component-installable --noack \ + --snap-revision 2 \ + --component-revision 1 \ + --snap-id "${kernel_id}" \ + "${NESTED_FAKESTORE_BLOB_DIR}" \ + ./pc-kernel+wifi-comp.comp + + boot_id="$(tests.nested boot-id)" + change_id=$(remote.exec "sudo snap refresh --no-wait pc-kernel+wifi-comp") + remote.wait-for reboot "${boot_id}" + remote.exec "snap watch ${change_id}" + + remote.exec "snap components pc-kernel" | sed 1d | MATCH 'pc-kernel\+wifi-comp\s+installed' + + # make sure that the kernel module got installed and is loaded + remote.exec sudo modprobe mac80211_hwsim + remote.exec ip link show wlan0 + + boot_id="$(tests.nested boot-id)" + change_id=$(post_json_data /v2/systems '{"action": "create", "label": "new-system", "mark-default": true, "test-system": true}') + remote.wait-for reboot "${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' + + boot_id="$(tests.nested boot-id)" + remote.exec "sudo snap reboot --recover" || true + remote.wait-for reboot "${boot_id}" + + remote.wait-for snap-command + + #shellcheck source=tests/lib/core-config.sh + . "$TESTSLIB"/core-config.sh + wait_for_first_boot_change + + remote.exec "sudo snap wait system seed.loaded" + + boot_id="$(tests.nested boot-id)" + + 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 + + # this at least indicates that we can have components in the recovery system, + # but kernel module components are not yet fully functional + remote.exec "snap components pc-kernel" | sed 1d | MATCH 'pc-kernel\+wifi-comp\s+installed' + remote.exec "readlink /snap/pc-kernel/components/2/wifi-comp" | MATCH "\.\./mnt/wifi-comp/1" + + # TODO:COMPS: snap-bootstrap needs to be modified to mount the kernel modules + # from /var/lib/snapd/kernel, rather than from the kernel snap directly. once + # that is done, then the module should be able to be loaded while in recover + # mode + not remote.exec sudo modprobe mac80211_hwsim