diff --git a/overlord/devicestate/devicestate.go b/overlord/devicestate/devicestate.go index a57f180d596..06e3e1e6a80 100644 --- a/overlord/devicestate/devicestate.go +++ b/overlord/devicestate/devicestate.go @@ -57,14 +57,16 @@ import ( ) var ( - snapstateDownloadComponents = snapstate.DownloadComponents - snapstateDownload = snapstate.Download - snapstateUpdateOne = snapstate.UpdateOne - snapstateInstallOne = snapstate.InstallOne - snapstateStoreInstallGoal = snapstate.StoreInstallGoal - snapstatePathInstallGoal = snapstate.PathInstallGoal - snapstateStoreUpdateGoal = snapstate.StoreUpdateGoal - snapstatePathUpdateGoal = snapstate.PathUpdateGoal + snapstateDownloadComponents = snapstate.DownloadComponents + snapstateDownload = snapstate.Download + snapstateUpdateOne = snapstate.UpdateOne + snapstateInstallOne = snapstate.InstallOne + snapstateStoreInstallGoal = snapstate.StoreInstallGoal + snapstatePathInstallGoal = snapstate.PathInstallGoal + snapstateStoreUpdateGoal = snapstate.StoreUpdateGoal + snapstatePathUpdateGoal = snapstate.PathUpdateGoal + snapstateInstallComponents = snapstate.InstallComponents + snapstateInstallComponentPath = snapstate.InstallComponentPath ) // findModel returns the device model assertion. @@ -476,9 +478,22 @@ const ( remodelChannelSwitch remodelInstallAction remodelUpdateAction + remodelAddComponentsAction ) func (r *remodeler) maybeInstallOrUpdate(ctx context.Context, st *state.State, rt remodelSnapTarget) (remodelAction, []*state.TaskSet, error) { + var requiredComponents, optionalComponents []string + if ms := rt.newModelSnap; ms != nil { + for comp, mc := range ms.Components { + switch mc.Presence { + case "required": + requiredComponents = append(requiredComponents, comp) + case "optional": + optionalComponents = append(optionalComponents, comp) + } + } + } + var snapst snapstate.SnapState if err := snapstate.Get(st, rt.name, &snapst); err != nil { if !errors.Is(err, state.ErrNoState) { @@ -492,7 +507,7 @@ func (r *remodeler) maybeInstallOrUpdate(ctx context.Context, st *state.State, r return remodelNoAction, nil, nil } - goal, err := r.installGoal(rt) + goal, err := r.installGoal(rt, requiredComponents) if err != nil { return 0, nil, err } @@ -538,21 +553,59 @@ func (r *remodeler) maybeInstallOrUpdate(ctx context.Context, st *state.State, r } // we need to change the revision if either the incoming model's validation - // sets require a specific revision that we don't have installed + // sets require a specific revision that we don't have installed, or if the + // current revision doesn't support the components that we need. + needsRevisionChange := (!constraints.Revision.Unset() && constraints.Revision != snapst.Current) || !revisionSupportsComponents(currentInfo, requiredComponents) + + // check if any components are either missing, or installed at the wrong + // revision. note that we will only explicitly handle these needed changes + // if the snap itself, and its channel, are already valid in the incoming + // model + needsComponentChanges := false + for _, c := range requiredComponents { + csi := snapst.CurrentComponentSideInfo(naming.NewComponentRef(rt.name, c)) + if csi == nil { + needsComponentChanges = true + break + } + + compConstraints := constraints.Component(c) + if !compConstraints.Revision.Unset() && compConstraints.Revision != csi.Revision { + needsComponentChanges = true + break + } + } + + // if we're not changing the revision, then we have to check if any of the + // model's optional components are installed and make sure that they are at + // the correct revision. if they aren't then we'll either attempt to update + // them from the store or they must come from a given file. // - // TODO: if the current revision doesn't support the components that we - // need, will also need to change the revision here - needsRevisionChange := (!constraints.Revision.Unset() && constraints.Revision != snapst.Current) - - // TODO: we don't properly handle snaps and components that are invalid in - // the incoming model and required by the previous model. this would require - // removing things during a remodel, which isn't something we do at the - // moment. afaict, there it is impossible to remodel from a model that + // TODO: this code is complicated, try and make it simpler + if !needsRevisionChange { + for _, c := range optionalComponents { + csi := snapst.CurrentComponentSideInfo(naming.NewComponentRef(rt.name, c)) + if csi == nil { + continue + } + + compConstraints := constraints.Component(c) + if !compConstraints.Revision.Unset() && compConstraints.Revision != csi.Revision { + needsComponentChanges = true + requiredComponents = append(requiredComponents, c) + } + } + } + + // TODO: we don't properly handle snaps (and now components) that are + // invalid in the incoming model and required by the previous model. this + // would require removing things during a remodel, which isn't something we + // do at the moment. afaict, it is impossible to remodel from a model that // requires a snap that is invalid in the incoming model. switch { case needsRevisionChange || needsChannelChange: - if r.shouldJustSwitch(rt, needsRevisionChange) { + if r.shouldSwitchWithoutRefresh(rt, needsRevisionChange, needsComponentChanges) { ts, err := snapstate.Switch(st, rt.name, &snapstate.RevisionOptions{ Channel: rt.channel, }) @@ -563,7 +616,18 @@ func (r *remodeler) maybeInstallOrUpdate(ctx context.Context, st *state.State, r return remodelChannelSwitch, []*state.TaskSet{ts}, nil } - goal, err := r.updateGoal(st, rt, constraints) + // right now, we don't properly handle switching a channel and + // installing components at the same time. in the meantime, we can use + // snapstate.UpdateOne to add additional components and switch the + // channel for us. this method is suboptimal, since we're creating tasks + // for essentially re-installing the snap. + // + // this also will not work well for offline remodeling, since it + // prevents us from using a combination of locally provided components + // and an already installed snap. for that case, + // snapstate.InstallComponents would need to support switching channels + // at the same time as installing components. + goal, err := r.updateGoal(st, rt, requiredComponents, constraints) if err != nil { return 0, nil, err } @@ -585,6 +649,12 @@ func (r *remodeler) maybeInstallOrUpdate(ctx context.Context, st *state.State, r } return remodelChannelSwitch, []*state.TaskSet{ts}, nil + case needsComponentChanges: + tss, err := r.installComponents(ctx, st, currentInfo, requiredComponents) + if err != nil { + return 0, nil, err + } + return remodelAddComponentsAction, tss, nil default: // nothing to do but add the snap to the prereq tracker r.tracker.Add(currentInfo) @@ -592,12 +662,12 @@ func (r *remodeler) maybeInstallOrUpdate(ctx context.Context, st *state.State, r } } -func (r *remodeler) shouldJustSwitch(rt remodelSnapTarget, needsRevisionChange bool) bool { +func (r *remodeler) shouldSwitchWithoutRefresh(rt remodelSnapTarget, needsRevisionChange bool, needsComponentChanges bool) bool { if !r.offline { return false } - if needsRevisionChange { + if needsRevisionChange || needsComponentChanges { return false } @@ -610,13 +680,26 @@ func (r *remodeler) shouldJustSwitch(rt remodelSnapTarget, needsRevisionChange b return true } -func (r *remodeler) installGoal(sn remodelSnapTarget) (snapstate.InstallGoal, error) { +func revisionSupportsComponents(info *snap.Info, components []string) bool { + for _, c := range components { + if _, ok := info.Components[c]; !ok { + return false + } + } + return true +} + +func (r *remodeler) installGoal(sn remodelSnapTarget, components []string) (snapstate.InstallGoal, error) { if r.offline { ls, ok := r.localSnaps[sn.name] if !ok { return nil, fmt.Errorf("no snap file provided for %q", sn.name) } + if len(components) != 0 { + return nil, errors.New("internal error: offline remodel with components not supported yet") + } + opts := snapstate.RevisionOptions{ Channel: sn.channel, ValidationSets: r.vsets, @@ -631,6 +714,7 @@ func (r *remodeler) installGoal(sn remodelSnapTarget) (snapstate.InstallGoal, er return snapstateStoreInstallGoal(snapstate.StoreSnap{ InstanceName: sn.name, + Components: components, RevOpts: snapstate.RevisionOptions{ Channel: sn.channel, ValidationSets: r.vsets, @@ -645,10 +729,15 @@ func (r *remodeler) installGoal(sn remodelSnapTarget) (snapstate.InstallGoal, er func (r *remodeler) installedRevisionUpdateGoal( st *state.State, sn remodelSnapTarget, + components []string, constraints snapasserts.SnapPresenceConstraints, ) (snapstate.UpdateGoal, error) { + if len(components) > 0 { + return nil, errors.New("internal error: falling back to previous snap with components not supported during remodel") + } + if constraints.Revision.Unset() { - return nil, errors.New("internal error: falling back to a previous revision requires that we have a speicifc revision to pick") + return nil, errors.New("internal error: falling back to a previous revision requires that we have a specific revision to pick") } var snapst snapstate.SnapState @@ -661,6 +750,10 @@ func (r *remodeler) installedRevisionUpdateGoal( return nil, fmt.Errorf("installed snap %q does not have the required revision in its sequence to be used for offline remodel: %s", sn.name, constraints.Revision) } + if snapst.Sequence.HasComponents(index) { + return nil, errors.New("TODO: snapstate currently reaches out to the store during a refresh if the snap has components already installed, regardless if the snap is already installed or not") + } + return snapstateStoreUpdateGoal(snapstate.StoreUpdate{ InstanceName: sn.name, RevOpts: snapstate.RevisionOptions{ @@ -671,20 +764,24 @@ func (r *remodeler) installedRevisionUpdateGoal( }), nil } -func (r *remodeler) updateGoal(st *state.State, sn remodelSnapTarget, constraints snapasserts.SnapPresenceConstraints) (snapstate.UpdateGoal, error) { +func (r *remodeler) updateGoal(st *state.State, sn remodelSnapTarget, components []string, constraints snapasserts.SnapPresenceConstraints) (snapstate.UpdateGoal, error) { if r.offline { ls, ok := r.localSnaps[sn.name] if !ok { // this attempts to create a snapstate.StoreUpdateGoal that will // switch back to a previously installed snap revision that is still // in the sequence - g, err := r.installedRevisionUpdateGoal(st, sn, constraints) + g, err := r.installedRevisionUpdateGoal(st, sn, components, constraints) if err != nil { return nil, err } return g, nil } + if len(components) != 0 { + return nil, errors.New("internal error: offline remodel with components not supported yet") + } + opts := snapstate.RevisionOptions{ Channel: sn.channel, ValidationSets: r.vsets, @@ -706,9 +803,27 @@ func (r *remodeler) updateGoal(st *state.State, sn remodelSnapTarget, constraint Channel: sn.channel, ValidationSets: r.vsets, }, + // components will be the full list of components needed by the new + // model, and it might already contain any of the components that are + // already installed. the snapstate code handles this case correctly. + AdditionalComponents: components, }), nil } +func (r *remodeler) installComponents(ctx context.Context, st *state.State, info *snap.Info, components []string) ([]*state.TaskSet, error) { + r.tracker.Add(info) + + if r.offline { + return nil, errors.New("internal error: offline remodel with components not supported yet") + } + + return snapstateInstallComponents(ctx, st, components, info, r.vsets, snapstate.Options{ + DeviceCtx: r.deviceCtx, + FromChange: r.fromChange, + PrereqTracker: r.tracker, + }) +} + func remodelEssentialSnapTasks( ctx context.Context, st *state.State, @@ -779,7 +894,7 @@ func remodelEssentialSnapTasks( // if we're updating or installing a new essential snap, everything will // already be handled return tss, nil - case remodelNoAction: + case remodelNoAction, remodelAddComponentsAction: ts, err := switchEssentialTasks(ms.newSnap, rm.fromChange) if err != nil { return nil, err @@ -998,10 +1113,6 @@ func remodelTasks(ctx context.Context, st *state.State, current, new *asserts.Mo return nil, errors.New(builder.String()) } - // Keep track of downloads tasks carrying snap-setup which is needed for - // recovery system tasks - var snapSetupTasks []string - // Ensure all download/check tasks are run *before* the install // tasks. During a remodel the network may not be available so // we need to ensure we have everything local. @@ -1059,8 +1170,6 @@ func remodelTasks(ctx context.Context, st *state.State, current, new *asserts.Mo if firstInstallInChain == nil { firstInstallInChain = installFirst } - // download is always a first task of the 'download' phase - snapSetupTasks = append(snapSetupTasks, downloadStart.ID()) } // Make sure the first install waits for the recovery system (only in // UC20) which waits for the last download. With this our (simplified) @@ -1100,8 +1209,12 @@ 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 - // TODO:COMPS - pass in the list of component setup tasks - createRecoveryTasks, err := createRecoverySystemTasks(st, label, snapSetupTasks, nil, CreateRecoverySystemOptions{ + snapsupTaskIDs, compsupTaskIDs, err := setupTaskIDsForCreatingRecoverySystem(tss) + if err != nil { + return nil, err + } + + createRecoveryTasks, err := createRecoverySystemTasks(st, label, snapsupTaskIDs, compsupTaskIDs, CreateRecoverySystemOptions{ TestSystem: true, }) if err != nil { @@ -1839,7 +1952,7 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst return nil, errors.New(builder.String()) } - snapsupTaskIDs, compsupTaskIDs, err := extractSnapSetupTaskIDs(downloadTSS) + snapsupTaskIDs, compsupTaskIDs, err := setupTaskIDsForCreatingRecoverySystem(downloadTSS) if err != nil { return nil, err } @@ -1933,30 +2046,52 @@ func installedComponentRevision(st *state.State, snapName, compName string) (boo return true, csi.Revision, nil } -func extractSnapSetupTaskIDs(tss []*state.TaskSet) (snapsupTaskIDs, compsupTaskIDs []string, err error) { +func setupTaskIDsForCreatingRecoverySystem(tss []*state.TaskSet) (snapsupTaskIDs, compsupTaskIDs []string, err error) { for _, ts := range tss { - var snapsupTask *state.Task + logger.Debugf("there are %d tasks in task set", len(ts.Tasks())) for _, t := range ts.Tasks() { if t.Has("snap-setup") { - snapsupTask = t - break + snapsup, err := snapstate.TaskSnapSetup(t) + if err != nil { + return nil, nil, err + } + + if !snapsup.ComponentExclusiveSetup { + snapsupTaskIDs = append(snapsupTaskIDs, t.ID()) + } + + var compsups []string + if err := t.Get("component-setup-tasks", &compsups); err != nil && !errors.Is(err, state.ErrNoState) { + return nil, nil, err + } + + compsupTaskIDs = append(compsupTaskIDs, compsups...) } } + } - if snapsupTask == nil { - return nil, nil, errors.New("internal error: snap setup task missing from task set") - } + snapsupTaskIDs = unique(snapsupTaskIDs) + compsupTaskIDs = unique(compsupTaskIDs) + + sort.Strings(snapsupTaskIDs) + sort.Strings(compsupTaskIDs) - snapsupTaskIDs = append(snapsupTaskIDs, snapsupTask.ID()) + return snapsupTaskIDs, compsupTaskIDs, nil +} - var compsups []string - if err := snapsupTask.Get("component-setup-tasks", &compsups); err != nil && !errors.Is(err, state.ErrNoState) { - return nil, nil, err +func unique[T comparable](slice []T) []T { + keys := make(map[T]bool, len(slice)) + + var list []T + for _, entry := range slice { + if _, value := keys[entry]; value { + continue } - compsupTaskIDs = append(compsupTaskIDs, compsups...) + keys[entry] = true + list = append(list, entry) } - return snapsupTaskIDs, compsupTaskIDs, nil + return list } // 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 93dde57285c..25b0067c59a 100644 --- a/overlord/devicestate/devicestate_remodel_test.go +++ b/overlord/devicestate/devicestate_remodel_test.go @@ -26,6 +26,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "strconv" "strings" "time" @@ -35,6 +36,7 @@ import ( "github.com/snapcore/snapd/asserts" "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/snapasserts" "github.com/snapcore/snapd/boot" "github.com/snapcore/snapd/gadget" "github.com/snapcore/snapd/gadget/quantity" @@ -46,11 +48,13 @@ import ( "github.com/snapcore/snapd/overlord/devicestate/devicestatetest" "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/overlord/storecontext" "github.com/snapcore/snapd/release" "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/naming" "github.com/snapcore/snapd/snap/snapfile" "github.com/snapcore/snapd/snap/snaptest" "github.com/snapcore/snapd/store/storetest" @@ -3139,6 +3143,12 @@ func (s *deviceMgrRemodelSuite) TestRemodelOfflineUseInstalledSnaps(c *C) { tValidateApp, tInstallApp, tCreateRecovery, tFinalizeRecovery, }) + + snapsups := []interface{}{tPrepareKernel.ID(), tPrepareBase.ID(), tPrepareGadget.ID(), tValidateApp.ID()} + sort.Slice(snapsups, func(i, j int) bool { + return snapsups[i].(string) < snapsups[j].(string) + }) + // verify recovery system setup data on appropriate tasks var systemSetupData map[string]interface{} err = tCreateRecovery.Get("recovery-system-setup", &systemSetupData) @@ -3146,7 +3156,7 @@ func (s *deviceMgrRemodelSuite) TestRemodelOfflineUseInstalledSnaps(c *C) { c.Assert(systemSetupData, DeepEquals, map[string]interface{}{ "label": expectedLabel, "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), - "snap-setup-tasks": []interface{}{tPrepareKernel.ID(), tPrepareBase.ID(), tPrepareGadget.ID(), tValidateApp.ID()}, + "snap-setup-tasks": snapsups, "test-system": true, }) } @@ -3403,6 +3413,12 @@ func (s *deviceMgrRemodelSuite) TestRemodelOfflineUseInstalledSnapsChannelSwitch tValidateApp, tInstallApp, tCreateRecovery, tFinalizeRecovery, }) + + snapsups := []interface{}{tSwitchKernel.ID(), tPrepareBase.ID(), tSwitchGadget.ID(), tValidateApp.ID()} + sort.Slice(snapsups, func(i, j int) bool { + return snapsups[i].(string) < snapsups[j].(string) + }) + // verify recovery system setup data on appropriate tasks var systemSetupData map[string]interface{} err = tCreateRecovery.Get("recovery-system-setup", &systemSetupData) @@ -3410,7 +3426,7 @@ func (s *deviceMgrRemodelSuite) TestRemodelOfflineUseInstalledSnapsChannelSwitch c.Assert(systemSetupData, DeepEquals, map[string]interface{}{ "label": expectedLabel, "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), - "snap-setup-tasks": []interface{}{tSwitchKernel.ID(), tPrepareBase.ID(), tSwitchGadget.ID(), tValidateApp.ID()}, + "snap-setup-tasks": snapsups, "test-system": true, }) } @@ -4036,6 +4052,14 @@ func (s *deviceMgrRemodelSuite) TestRemodelUC20SwitchKernelBaseSnapsInstalledSna c.Check(opts.DeviceCtx, NotNil) tDownload := s.state.NewTask("fake-download", fmt.Sprintf("Download %s from track %s", name, channel)) + tDownload.Set("snap-setup", &snapstate.SnapSetup{ + SideInfo: &snap.SideInfo{ + RealName: name, + Revision: snap.R(10), + }, + Channel: channel, + }) + tValidate := s.state.NewTask("validate-snap", fmt.Sprintf("Validate %s", name)) tValidate.WaitFor(tDownload) tUpdate := s.state.NewTask("fake-update", fmt.Sprintf("Update %s to track %s", name, channel)) @@ -6662,6 +6686,1197 @@ func (f *fakeSequenceStore) SeqFormingAssertion(assertType *asserts.AssertionTyp return f.fn(assertType, sequenceKey, sequence, user) } +type expectedSnap struct { + name string + revision snap.Revision + snapType snap.Type + snapFiles [][]string + components map[string]expectedComponent +} + +type expectedComponent struct { + name string + revision snap.Revision + compType snap.ComponentType +} + +func mockSnapstateUpdateOne(c *C, snaps map[string]expectedSnap) (restore func(), updated map[string]string) { + updated = make(map[string]string) + mock := func( + ctx context.Context, + st *state.State, + goal snapstate.UpdateGoal, + filter func(*snap.Info, *snapstate.SnapState) bool, + opts snapstate.Options, + ) (*state.TaskSet, error) { + g := goal.(*storeUpdateGoalRecorder) + name := g.snaps[0].InstanceName + rev := g.snaps[0].RevOpts.Revision + components := g.snaps[0].AdditionalComponents + + // snapstate handles picking the right revision based on the given + // validation sets + c.Assert(rev.Unset(), Equals, true) + + expected, ok := snaps[name] + c.Assert(ok, Equals, true, Commentf("unexpected snap update: %q", name)) + + download := st.NewTask("fake-download", "download snap") + + si := snap.SideInfo{ + RealName: expected.name, + SnapID: snaptest.AssertedSnapID(expected.name), + Revision: expected.revision, + } + download.Set("snap-setup", snapstate.SnapSetup{ + SideInfo: &si, + Type: expected.snapType, + }) + + updated[name] = download.ID() + + 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 := st.NewTask("validate-snap", "validate snap") + add(validate) + + compsupTaskIDMapping := make(map[string]string, len(components)) + compsupTaskIDs := make([]string, 0, len(components)) + lastBeforeLocalModifications := validate + for _, comp := range components { + expectedComp, ok := expected.components[comp] + c.Assert(ok, Equals, true) + + cref := naming.NewComponentRef(name, comp) + + download := st.NewTask("mock-download-component", "download component") + download.Set("component-setup", &snapstate.ComponentSetup{ + CompSideInfo: &snap.ComponentSideInfo{ + Component: cref, + Revision: expectedComp.revision, + }, + CompType: expectedComp.compType, + }) + + updated[cref.String()] = download.ID() + compsupTaskIDs = append(compsupTaskIDs, download.ID()) + compsupTaskIDMapping[comp] = download.ID() + add(download) + + validate := st.NewTask("mock-validate-component", "validate component") + validate.Set("component-setup-task", download.ID()) + add(validate) + + lastBeforeLocalModifications = validate + } + ts.MarkEdge(lastBeforeLocalModifications, snapstate.LastBeforeLocalModificationsEdge) + + download.Set("component-setup-tasks", compsupTaskIDs) + + link := st.NewTask("link-snap", "link snap") + add(link) + + for _, comp := range components { + link := st.NewTask("link-component", "link component") + link.Set("component-setup-task", compsupTaskIDMapping[comp]) + add(link) + } + + yaml := fmt.Sprintf("name: %s\nversion: 1\ntype: %s", name, expected.snapType) + if expected.snapType == "app" { + yaml += fmt.Sprintf("\nbase: %s", "core24") + } + + compTypes := make(map[string]snap.ComponentType, len(expected.components)) + for _, comp := range expected.components { + compTypes[comp.name] = comp.compType + } + + _, info := snaptest.MakeTestSnapInfoWithFiles(c, withComponents(yaml, compTypes), expected.snapFiles, &si) + opts.PrereqTracker.Add(info) + + opts.PrereqTracker.Add(info) + + return ts, nil + } + + return devicestate.MockSnapstateUpdateOne(mock), updated +} + +func mockSnapstateInstallOne(c *C, snaps map[string]expectedSnap) (restore func(), installed map[string]string) { + installed = make(map[string]string) + mock := func( + ctx context.Context, + st *state.State, + goal snapstate.InstallGoal, + opts snapstate.Options, + ) (*snap.Info, *state.TaskSet, error) { + g := goal.(*storeInstallGoalRecorder) + name := g.snaps[0].InstanceName + rev := g.snaps[0].RevOpts.Revision + components := g.snaps[0].Components + + // snapstate handles picking the right revision based on the given + // validation sets + c.Assert(rev.Unset(), Equals, true) + + expected, ok := snaps[name] + c.Assert(ok, Equals, true, Commentf("unexpected snap installation: %q", name)) + + download := st.NewTask("fake-download", "download snap") + + si := snap.SideInfo{ + RealName: expected.name, + SnapID: fakeSnapID(expected.name), + Revision: expected.revision, + } + download.Set("snap-setup", snapstate.SnapSetup{ + SideInfo: &si, + Type: expected.snapType, + }) + installed[expected.name] = download.ID() + + 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 := st.NewTask("validate-snap", "validate snap") + add(validate) + + compsupTaskIDMapping := make(map[string]string, len(components)) + compsupTaskIDs := make([]string, 0, len(components)) + lastBeforeLocalModifications := validate + for _, comp := range components { + expectedComp, ok := expected.components[comp] + c.Assert(ok, Equals, true) + + cref := naming.NewComponentRef(name, comp) + + download := st.NewTask("mock-download-component", "download component") + download.Set("component-setup", &snapstate.ComponentSetup{ + CompSideInfo: &snap.ComponentSideInfo{ + Component: cref, + Revision: expectedComp.revision, + }, + CompType: expectedComp.compType, + }) + installed[cref.String()] = download.ID() + compsupTaskIDs = append(compsupTaskIDs, download.ID()) + compsupTaskIDMapping[comp] = download.ID() + add(download) + + validate := st.NewTask("mock-validate-component", "validate component") + validate.Set("component-setup-task", download.ID()) + add(validate) + + lastBeforeLocalModifications = validate + } + ts.MarkEdge(lastBeforeLocalModifications, snapstate.LastBeforeLocalModificationsEdge) + + download.Set("component-setup-tasks", compsupTaskIDs) + + link := st.NewTask("link-snap", "link snap") + add(link) + + for _, comp := range components { + link := st.NewTask("link-component", "link component") + link.Set("component-setup-task", compsupTaskIDMapping[comp]) + add(link) + } + + yaml := fmt.Sprintf("name: %s\nversion: 1\ntype: %s", name, expected.snapType) + if expected.snapType == "app" { + yaml += fmt.Sprintf("\nbase: %s", "core24") + } + + compTypes := make(map[string]snap.ComponentType, len(expected.components)) + for _, comp := range expected.components { + compTypes[comp.name] = comp.compType + } + + _, info := snaptest.MakeTestSnapInfoWithFiles(c, withComponents(yaml, compTypes), expected.snapFiles, &si) + opts.PrereqTracker.Add(info) + + opts.PrereqTracker.Add(info) + + return info, ts, nil + } + + return devicestate.MockSnapstateInstallOne(mock), installed +} + +func mockSnapstateInstallComponents(c *C, snaps map[string]expectedSnap) (restore func(), installed map[string]string) { + installed = make(map[string]string) + mock := func( + ctx context.Context, + st *state.State, + names []string, + info *snap.Info, + vsets *snapasserts.ValidationSets, + opts snapstate.Options, + ) ([]*state.TaskSet, error) { + sn, ok := snaps[info.InstanceName()] + c.Assert(ok, Equals, true, Commentf("unexpected component installation for snap: %q", info.InstanceName())) + c.Assert(info.Revision, Equals, sn.revision) + + setupSecurity := st.NewTask("setup-profiles", "setup profiles") + setupSecurity.Set("snap-setup", snapstate.SnapSetup{ + SideInfo: &snap.SideInfo{ + RealName: sn.name, + SnapID: fakeSnapID(sn.name), + Revision: info.Revision, + }, + Type: info.Type(), + ComponentExclusiveSetup: true, + }) + + compsupTaskIDs := make([]string, 0, len(names)) + tss := make([]*state.TaskSet, 0, len(names)) + for _, name := range names { + expected, ok := sn.components[name] + c.Assert(ok, Equals, true, Commentf("unexpected component installation for snap %q: %q", info.InstanceName(), name)) + + cref := naming.NewComponentRef(info.SnapName(), name) + + download := st.NewTask("mock-download-component", "download component") + download.Set("component-setup", &snapstate.ComponentSetup{ + CompSideInfo: &snap.ComponentSideInfo{ + Component: cref, + Revision: expected.revision, + }, + CompType: expected.compType, + }) + compsupTaskIDs = append(compsupTaskIDs, download.ID()) + + validate := st.NewTask("mock-validate-component", "validate component") + validate.Set("component-setup-task", download.ID()) + validate.WaitFor(download) + + setupSecurity.WaitFor(validate) + + link := st.NewTask("link-component", "link component") + link.Set("component-setup-task", download.ID()) + link.WaitFor(setupSecurity) + + ts := state.NewTaskSet(download, validate, link) + ts.MarkEdge(validate, snapstate.LastBeforeLocalModificationsEdge) + + tss = append(tss, ts) + + installed[cref.String()] = download.ID() + } + + setupSecurity.Set("component-setup-tasks", compsupTaskIDs) + + return append(tss, state.NewTaskSet(setupSecurity)), nil + } + + return devicestate.MockSnapstateInstallComponents(mock), installed +} + +func (s *deviceMgrRemodelSuite) TestRemodelWithComponents(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.state.Set("seeded", true) + s.state.Set("refresh-privacy-key", "some-privacy-key") + + expectedUpdates := map[string]expectedSnap{ + "pc-kernel": { + name: "pc-kernel", + snapType: "kernel", + components: map[string]expectedComponent{ + "kmod": { + name: "kmod", + revision: snap.R(11), + compType: snap.KernelModulesComponent, + }, + }, + }, + } + + now := time.Now() + restore := devicestate.MockTimeNow(func() time.Time { return now }) + defer restore() + + restore, updated := mockSnapstateUpdateOne(c, expectedUpdates) + defer restore() + + expectedInstalls := map[string]expectedSnap{ + "some-snap": { + name: "some-snap", + snapType: "app", + }, + } + + restore, installed := mockSnapstateInstallOne(c, expectedInstalls) + defer restore() + + currentModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + err := assertstate.Add(s.state, currentModel) + c.Assert(err, IsNil) + + err = devicestatetest.SetDevice(s.state, &auth.DeviceState{ + Brand: "canonical", + Model: "pc-model", + }) + c.Assert(err, IsNil) + + snapstatetest.InstallEssentialSnaps(c, s.state, "core24", nil, nil) + + newModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "presence": "required", + }, + }, + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "some-snap", + "id": fakeSnapID("some-snap"), + "type": "app", + "default-channel": "latest/stable", + }, + }, + }) + + testDeviceCtx := snapstatetest.TrivialDeviceContext{ + Remodeling: true, + DeviceModel: newModel, + OldDeviceModel: currentModel, + } + + tss, err := devicestate.RemodelTasks( + context.Background(), + s.state, + currentModel, + newModel, + &testDeviceCtx, + "99", + nil, + devicestate.RemodelOptions{}, + ) + c.Assert(err, IsNil) + + // snap update (with component install), create recovery system, set model + c.Assert(tss, HasLen, 4) + + updateTS := tss[0] + checkTaskSetKinds(c, updateTS, []string{ + "fake-download", + "validate-snap", + "mock-download-component", + "mock-validate-component", + "link-snap", + "link-component", + }) + + installTS := tss[1] + checkTaskSetKinds(c, installTS, []string{ + "fake-download", + "validate-snap", + "link-snap", + }) + + c.Assert(mapKeys(updated), testutil.DeepUnsortedMatches, []string{ + "pc-kernel", + "pc-kernel+kmod", + }) + + c.Assert(mapKeys(installed), testutil.DeepUnsortedMatches, []string{ + "some-snap", + }) + + createRecoverySystem := tss[2].Tasks()[0] + checkRecoverySystemSetup(createRecoverySystem, c, now, []interface{}{ + updated["pc-kernel"], installed["some-snap"], + }, []interface{}{ + updated["pc-kernel+kmod"], + }) +} + +func checkRecoverySystemSetup(t *state.Task, c *C, now time.Time, snapsups, compsups []interface{}) { + var data map[string]interface{} + err := t.Get("recovery-system-setup", &data) + c.Assert(err, IsNil) + + label := now.Format("20060102") + expected := map[string]interface{}{ + "label": label, + "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", label), + "test-system": true, + } + if snapsups != nil { + expected["snap-setup-tasks"] = snapsups + } + if compsups != nil { + expected["component-setup-tasks"] = compsups + } +} + +func mapKeys[K comparable, V any](m map[K]V) []K { + keys := make([]K, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func (s *deviceMgrRemodelSuite) TestRemodelWithComponentsNewSnapAndComponent(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.state.Set("seeded", true) + s.state.Set("refresh-privacy-key", "some-privacy-key") + + now := time.Now() + restore := devicestate.MockTimeNow(func() time.Time { return now }) + defer restore() + + expectedInstalls := map[string]expectedSnap{ + "snap-with-comps": { + name: "snap-with-comps", + snapType: "app", + components: map[string]expectedComponent{ + "comp-1": { + name: "comp-1", + revision: snap.R(11), + compType: snap.StandardComponent, + }, + }, + }, + } + + restore, installed := mockSnapstateInstallOne(c, expectedInstalls) + defer restore() + + currentModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + err := assertstate.Add(s.state, currentModel) + c.Assert(err, IsNil) + + err = devicestatetest.SetDevice(s.state, &auth.DeviceState{ + Brand: "canonical", + Model: "pc-model", + }) + c.Assert(err, IsNil) + + snapstatetest.InstallEssentialSnaps(c, s.state, "core24", nil, nil) + + newModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snap-with-comps", + "id": fakeSnapID("snap-with-comps"), + "type": "app", + "default-channel": "latest/stable", + "components": map[string]interface{}{ + "comp-1": map[string]interface{}{ + "presence": "required", + }, + }, + }, + }, + }) + + testDeviceCtx := snapstatetest.TrivialDeviceContext{ + Remodeling: true, + DeviceModel: newModel, + OldDeviceModel: currentModel, + } + + tss, err := devicestate.RemodelTasks( + context.Background(), + s.state, + currentModel, + newModel, + &testDeviceCtx, + "99", + nil, + devicestate.RemodelOptions{}, + ) + c.Assert(err, IsNil) + + // snap install (with component install), create recovery system, set model + c.Assert(tss, HasLen, 3) + + updateTS := tss[0] + checkTaskSetKinds(c, updateTS, []string{ + "fake-download", + "validate-snap", + "mock-download-component", + "mock-validate-component", + "link-snap", + "link-component", + }) + + c.Assert(mapKeys(installed), testutil.DeepUnsortedMatches, []string{ + "snap-with-comps", + "snap-with-comps+comp-1", + }) + + createRecoverySystem := tss[1].Tasks()[0] + checkRecoverySystemSetup(createRecoverySystem, c, now, []interface{}{ + installed["snap-with-comps"], + }, []interface{}{ + installed["snap-with-comps+comp-1"], + }) +} + +func (s *deviceMgrRemodelSuite) TestRemodelWithComponentsAddComponentsToSnap(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.state.Set("seeded", true) + s.state.Set("refresh-privacy-key", "some-privacy-key") + + now := time.Now() + restore := devicestate.MockTimeNow(func() time.Time { return now }) + defer restore() + + expectedComponentInstalls := map[string]expectedSnap{ + "pc-kernel": { + name: "pc-kernel", + snapType: "kernel", + revision: snap.R(1), + components: map[string]expectedComponent{ + "kmod": { + name: "kmod", + revision: snap.R(11), + compType: snap.KernelModulesComponent, + }, + "other-kmod": { + name: "other-kmod", + revision: snap.R(12), + compType: snap.KernelModulesComponent, + }, + }, + }, + } + + restore, installed := mockSnapstateInstallComponents(c, expectedComponentInstalls) + defer restore() + + currentModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + err := assertstate.Add(s.state, currentModel) + c.Assert(err, IsNil) + + err = devicestatetest.SetDevice(s.state, &auth.DeviceState{ + Brand: "canonical", + Model: "pc-model", + }) + c.Assert(err, IsNil) + + snapstatetest.InstallEssentialSnaps(c, s.state, "core24", nil, nil) + + kmodComps := map[string]snap.ComponentType{ + "kmod": snap.KernelModulesComponent, + "other-kmod": snap.KernelModulesComponent, + } + + snapstatetest.InstallSnap(c, s.state, withComponents("name: pc-kernel\nversion: 1\ntype: kernel\n", kmodComps), nil, &snap.SideInfo{ + SnapID: fakeSnapID("pc-kernel"), + Revision: snap.R(1), + RealName: "pc-kernel", + Channel: "latest/stable", + }, snapstatetest.InstallSnapOptions{Required: true}) + + newModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "presence": "required", + }, + "other-kmod": map[string]interface{}{ + "presence": "required", + }, + }, + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + + testDeviceCtx := snapstatetest.TrivialDeviceContext{ + Remodeling: true, + DeviceModel: newModel, + OldDeviceModel: currentModel, + } + + tss, err := devicestate.RemodelTasks( + context.Background(), + s.state, + currentModel, + newModel, + &testDeviceCtx, + "99", + nil, + devicestate.RemodelOptions{}, + ) + c.Assert(err, IsNil) + + // component install (x2), component security setup, create recovery system, set model + c.Assert(tss, HasLen, 5) + + installTS := tss[0] + checkTaskSetKinds(c, installTS, []string{ + "mock-download-component", + "mock-validate-component", + "link-component", + }) + + installTS = tss[1] + checkTaskSetKinds(c, installTS, []string{ + "mock-download-component", + "mock-validate-component", + "link-component", + }) + + securityTS := tss[2] + checkTaskSetKinds(c, securityTS, []string{ + "setup-profiles", + }) + + c.Assert(mapKeys(installed), testutil.DeepUnsortedMatches, []string{ + "pc-kernel+kmod", + "pc-kernel+other-kmod", + }) + + createRecoverySystem := tss[3].Tasks()[0] + checkRecoverySystemSetup(createRecoverySystem, c, now, nil, []interface{}{ + installed["pc-kernel+kmod"], + installed["pc-kernel+other-kmod"], + }) +} + +func (s *deviceMgrRemodelSuite) TestRemodelWithComponentsSkipOptionalComponent(c *C) { + s.state.Lock() + defer s.state.Unlock() + + s.state.Set("seeded", true) + s.state.Set("refresh-privacy-key", "some-privacy-key") + + now := time.Now() + restore := devicestate.MockTimeNow(func() time.Time { return now }) + defer restore() + + currentModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + err := assertstate.Add(s.state, currentModel) + c.Assert(err, IsNil) + + err = devicestatetest.SetDevice(s.state, &auth.DeviceState{ + Brand: "canonical", + Model: "pc-model", + }) + c.Assert(err, IsNil) + + snapstatetest.InstallEssentialSnaps(c, s.state, "core24", nil, nil) + + kmodComps := map[string]snap.ComponentType{ + "kmod": snap.KernelModulesComponent, + } + + snapstatetest.InstallSnap(c, s.state, withComponents("name: pc-kernel\nversion: 1\ntype: kernel\n", kmodComps), nil, &snap.SideInfo{ + SnapID: fakeSnapID("pc-kernel"), + Revision: snap.R(1), + RealName: "pc-kernel", + Channel: "latest/stable", + }, snapstatetest.InstallSnapOptions{Required: true}) + + newModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "presence": "optional", + }, + }, + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + + testDeviceCtx := snapstatetest.TrivialDeviceContext{ + Remodeling: true, + DeviceModel: newModel, + OldDeviceModel: currentModel, + } + + tss, err := devicestate.RemodelTasks( + context.Background(), + s.state, + currentModel, + newModel, + &testDeviceCtx, + "99", + nil, + devicestate.RemodelOptions{}, + ) + c.Assert(err, IsNil) + + c.Assert(tss, HasLen, 2) + + createRecoverySystem := tss[0].Tasks()[0] + checkRecoverySystemSetup(createRecoverySystem, c, now, nil, nil) +} + +func (s *deviceMgrRemodelSuite) TestRemodelWithComponentsChangeBecauseOfValidationSetOptional(c *C) { + s.testRemodelWithComponentsChangeBecauseOfValidationSet(c, "optional") +} + +func (s *deviceMgrRemodelSuite) TestRemodelWithComponentsChangeBecauseOfValidationSetRequired(c *C) { + s.testRemodelWithComponentsChangeBecauseOfValidationSet(c, "required") +} + +func (s *deviceMgrRemodelSuite) testRemodelWithComponentsChangeBecauseOfValidationSet(c *C, componentPresence string) { + s.state.Lock() + defer s.state.Unlock() + + s.state.Set("seeded", true) + s.state.Set("refresh-privacy-key", "some-privacy-key") + + now := time.Now() + restore := devicestate.MockTimeNow(func() time.Time { return now }) + defer restore() + + expectedComponentInstalls := map[string]expectedSnap{ + "pc-kernel": { + name: "pc-kernel", + snapType: "kernel", + revision: snap.R(1), + components: map[string]expectedComponent{ + "kmod": { + name: "kmod", + revision: snap.R(12), + compType: snap.KernelModulesComponent, + }, + }, + }, + } + + restore, installed := mockSnapstateInstallComponents(c, expectedComponentInstalls) + defer restore() + + currentModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + err := assertstate.Add(s.state, currentModel) + c.Assert(err, IsNil) + + err = devicestatetest.SetDevice(s.state, &auth.DeviceState{ + Brand: "canonical", + Model: "pc-model", + }) + c.Assert(err, IsNil) + + snapstatetest.InstallEssentialSnaps(c, s.state, "core24", nil, nil) + + kmodComps := map[string]snap.ComponentType{ + "kmod": snap.KernelModulesComponent, + } + + snapstatetest.InstallSnap(c, s.state, withComponents("name: pc-kernel\nversion: 1\ntype: kernel\n", kmodComps), nil, &snap.SideInfo{ + SnapID: fakeSnapID("pc-kernel"), + Revision: snap.R(1), + RealName: "pc-kernel", + Channel: "latest/stable", + }, snapstatetest.InstallSnapOptions{Required: true}) + + var snapst snapstate.SnapState + err = snapstate.Get(s.state, "pc-kernel", &snapst) + c.Assert(err, IsNil) + + err = snapst.Sequence.AddComponentForRevision(snapst.Current, sequence.NewComponentState(&snap.ComponentSideInfo{ + Component: naming.NewComponentRef("pc-kernel", "kmod"), + Revision: snap.R(11), + }, snap.KernelModulesComponent)) + c.Assert(err, IsNil) + + snapstate.Set(s.state, "pc-kernel", &snapst) + + newModel := s.brands.Model("canonical", "pc-model", map[string]interface{}{ + "architecture": "amd64", + "base": "core24", + "validation-sets": []interface{}{ + map[string]interface{}{ + "account-id": "canonical", + "name": "vset-1", + "mode": "enforce", + }, + }, + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "latest/stable", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "presence": componentPresence, + }, + }, + }, + map[string]interface{}{ + "name": "pc", + "id": fakeSnapID("pc"), + "type": "gadget", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "core24", + "id": fakeSnapID("core24"), + "type": "base", + "default-channel": "latest/stable", + }, + map[string]interface{}{ + "name": "snapd", + "id": fakeSnapID("snapd"), + "type": "snapd", + "default-channel": "latest/stable", + }, + }, + }) + + 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": "pc-kernel", + "id": fakeSnapID("pc-kernel"), + "revision": "1", + "presence": "required", + "components": map[string]interface{}{ + "kmod": map[string]interface{}{ + "revision": "12", + "presence": "required", + }, + }, + }, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + + testDeviceCtx := snapstatetest.TrivialDeviceContext{ + Remodeling: true, + DeviceModel: newModel, + OldDeviceModel: currentModel, + CtxStore: &fakeSequenceStore{ + fn: func(aType *asserts.AssertionType, key []string, seq int, _ *auth.UserState) (asserts.Assertion, error) { + c.Check(aType, Equals, asserts.ValidationSetType) + c.Check(key, DeepEquals, []string{"16", "canonical", "vset-1"}) + c.Check(seq, Equals, 0) + return vset, nil + }, + }, + } + + tss, err := devicestate.RemodelTasks( + context.Background(), + s.state, + currentModel, + newModel, + &testDeviceCtx, + "99", + nil, + devicestate.RemodelOptions{}, + ) + c.Assert(err, IsNil) + + // component install, component security setup, create recovery system, set model + c.Assert(tss, HasLen, 4) + + installTS := tss[0] + checkTaskSetKinds(c, installTS, []string{ + "mock-download-component", + "mock-validate-component", + "link-component", + }) + + securityTS := tss[1] + checkTaskSetKinds(c, securityTS, []string{ + "setup-profiles", + }) + + c.Assert(mapKeys(installed), testutil.DeepUnsortedMatches, []string{ + "pc-kernel+kmod", + }) + + createRecoverySystem := tss[2].Tasks()[0] + checkRecoverySystemSetup(createRecoverySystem, c, now, nil, []interface{}{ + installed["pc-kernel+kmod"], + }) +} + +func checkTaskSetKinds(c *C, ts *state.TaskSet, kinds []string) { + c.Assert(ts.Tasks(), HasLen, len(kinds)) + + for _, t := range ts.Tasks() { + c.Check(t.Kind(), Equals, kinds[0]) + kinds = kinds[1:] + } + c.Check(kinds, HasLen, 0) +} + func (s *deviceMgrSuite) TestRemodelUpdateFromValidationSetLatest(c *C) { const sequence = "" s.testRemodelUpdateFromValidationSet(c, sequence) diff --git a/overlord/devicestate/export_test.go b/overlord/devicestate/export_test.go index a9de4195114..317169ab127 100644 --- a/overlord/devicestate/export_test.go +++ b/overlord/devicestate/export_test.go @@ -26,6 +26,7 @@ import ( "time" "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/snapasserts" "github.com/snapcore/snapd/boot" "github.com/snapcore/snapd/gadget" "github.com/snapcore/snapd/gadget/install" @@ -185,6 +186,14 @@ func MockSnapstatePathInstallGoal(mock func(snapstate.PathSnap) snapstate.Instal return testutil.Mock(&snapstatePathInstallGoal, mock) } +func MockSnapstateInstallComponents(mock func(ctx context.Context, st *state.State, names []string, info *snap.Info, vsets *snapasserts.ValidationSets, opts snapstate.Options) ([]*state.TaskSet, error)) (restore func()) { + return testutil.Mock(&snapstateInstallComponents, mock) +} + +func MockSnapstateInstallComponentPath(mock func(st *state.State, csi *snap.ComponentSideInfo, info *snap.Info, path string, opts snapstate.Options) (*state.TaskSet, error)) (restore func()) { + return testutil.Mock(&snapstateInstallComponentPath, mock) +} + func MockSnapstateDownload(f func(ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, *snap.Info, error)) (restore func()) { r := testutil.Backup(&snapstateDownload) snapstateDownload = f diff --git a/overlord/snapstate/component.go b/overlord/snapstate/component.go index 1523a1015f6..90b7a8bdc8e 100644 --- a/overlord/snapstate/component.go +++ b/overlord/snapstate/component.go @@ -92,14 +92,15 @@ func InstallComponents( // TODO:COMPS: verify validation sets here snapsup := SnapSetup{ - Base: info.Base, - SideInfo: &info.SideInfo, - Channel: info.Channel, - Flags: opts.Flags.ForSnapSetup(), - Type: info.Type(), - Version: info.Version, - PlugsOnly: len(info.Slots) == 0, - InstanceKey: info.InstanceKey, + Base: info.Base, + SideInfo: &info.SideInfo, + Channel: info.Channel, + Flags: opts.Flags.ForSnapSetup(), + Type: info.Type(), + Version: info.Version, + PlugsOnly: len(info.Slots) == 0, + InstanceKey: info.InstanceKey, + ComponentExclusiveSetup: true, } setupSecurity := st.NewTask("setup-profiles", @@ -264,14 +265,15 @@ func InstallComponentPath(st *state.State, csi *snap.ComponentSideInfo, info *sn } snapsup := SnapSetup{ - Base: info.Base, - SideInfo: &info.SideInfo, - Channel: info.Channel, - Flags: opts.Flags.ForSnapSetup(), - Type: info.Type(), - Version: info.Version, - PlugsOnly: len(info.Slots) == 0, - InstanceKey: info.InstanceKey, + Base: info.Base, + SideInfo: &info.SideInfo, + Channel: info.Channel, + Flags: opts.Flags.ForSnapSetup(), + Type: info.Type(), + Version: info.Version, + PlugsOnly: len(info.Slots) == 0, + InstanceKey: info.InstanceKey, + ComponentExclusiveSetup: true, } compSetup := ComponentSetup{ CompSideInfo: csi, @@ -289,6 +291,18 @@ func InstallComponentPath(st *state.State, csi *snap.ComponentSideInfo, info *sn } ts := componentTS.taskSet() + + // TODO:COMPS: instead of doing this, we should convert this function to + // operate on multiple components so that it works like InstallComponents. + // this would improve performance, especially in the case of kernel module + // components. + begin, err := ts.Edge(BeginEdge) + if err != nil { + return nil, fmt.Errorf("internal error: cannot find begin edge on component install task set: %v", err) + } + + begin.Set("component-setup-tasks", []string{componentTS.compSetupTaskID}) + ts.JoinLane(generateLane(st, opts)) return ts, nil diff --git a/overlord/snapstate/component_install_test.go b/overlord/snapstate/component_install_test.go index 4262003fca8..47ec0bbc909 100644 --- a/overlord/snapstate/component_install_test.go +++ b/overlord/snapstate/component_install_test.go @@ -502,6 +502,7 @@ func (s *snapmgrTestSuite) TestInstallComponentPathForParallelInstall(c *C) { var snapsup snapstate.SnapSetup c.Assert(ts.Tasks()[0].Get("snap-setup", &snapsup), IsNil) c.Assert(snapsup.InstanceKey, Equals, snapKey) + c.Assert(snapsup.ComponentExclusiveSetup, Equals, true) } func (s *snapmgrTestSuite) TestInstallComponentPathWrongSnap(c *C) { @@ -1052,6 +1053,7 @@ func (s *snapmgrTestSuite) testInstallComponents(c *C, opts testInstallComponent snapsup, err := snapstate.TaskSnapSetup(prepareKmodComps) c.Assert(err, IsNil) c.Assert(snapsup, NotNil) + c.Assert(snapsup.ComponentExclusiveSetup, Equals, true) for _, ts := range tss[0 : len(tss)-1] { task := ts.Tasks()[0] diff --git a/overlord/snapstate/snapmgr.go b/overlord/snapstate/snapmgr.go index e782234d79e..7364c738459 100644 --- a/overlord/snapstate/snapmgr.go +++ b/overlord/snapstate/snapmgr.go @@ -159,6 +159,10 @@ type SnapSetup struct { // case of an undo. Note that this cannot be tagged as omitempty, since we // need to distinguish between empty and nil. PreUpdateKernelModuleComponents []*snap.ComponentSideInfo `json:"pre-update-kernel-module-components"` + + // ComponentExclusiveSetup is set if this SnapSetup exists only to deal with + // components, and not the snap itself. + ComponentExclusiveSetup bool `json:"component-exclusive-setup,omitempty"` } // ConfdbID identifies a confdb. diff --git a/overlord/snapstate/snapstate.go b/overlord/snapstate/snapstate.go index 193569795c8..5a727e05fac 100644 --- a/overlord/snapstate/snapstate.go +++ b/overlord/snapstate/snapstate.go @@ -1641,18 +1641,19 @@ func downloadTasks( } snapsup := &SnapSetup{ - Channel: revOpts.Channel, - Base: info.Base, - UserID: opts.UserID, - Flags: opts.Flags.ForSnapSetup(), - DownloadInfo: &info.DownloadInfo, - SideInfo: &info.SideInfo, - Type: info.Type(), - Version: info.Version, - InstanceKey: info.InstanceKey, - CohortKey: revOpts.CohortKey, - ExpectedProvenance: info.SnapProvenance, - DownloadBlobDir: downloadDir, + Channel: revOpts.Channel, + Base: info.Base, + UserID: opts.UserID, + Flags: opts.Flags.ForSnapSetup(), + DownloadInfo: &info.DownloadInfo, + SideInfo: &info.SideInfo, + Type: info.Type(), + Version: info.Version, + InstanceKey: info.InstanceKey, + CohortKey: revOpts.CohortKey, + ExpectedProvenance: info.SnapProvenance, + DownloadBlobDir: downloadDir, + ComponentExclusiveSetup: skipSnapDownload, } if sar.RedirectChannel != "" { diff --git a/overlord/snapstate/snapstate_test.go b/overlord/snapstate/snapstate_test.go index 9adc34f6fd1..efe0b1b5570 100644 --- a/overlord/snapstate/snapstate_test.go +++ b/overlord/snapstate/snapstate_test.go @@ -10176,7 +10176,8 @@ func (s *snapmgrTestSuite) TestDownloadWithComponents(c *C) { c.Assert(begin, NotNil) c.Check(begin.Kind(), Equals, "download-snap") - verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir) + const componentExclusive = false + verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir, componentExclusive) } func (s *snapmgrTestSuite) TestDownloadWithComponentsWithMismatchValidationSets(c *C) { @@ -10388,7 +10389,8 @@ func (s *snapmgrTestSuite) TestDownloadWithComponentsWithValidationSets(c *C) { c.Assert(begin, NotNil) c.Check(begin.Kind(), Equals, "download-snap") - verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir) + const componentExclusive = false + verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir, componentExclusive) } func (s *snapmgrTestSuite) TestDownloadComponents(c *C) { @@ -10463,10 +10465,11 @@ func (s *snapmgrTestSuite) TestDownloadComponents(c *C) { c.Assert(begin, NotNil) c.Check(begin.Kind(), Equals, "download-component") - verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir) + const componentExclusive = true + verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir, componentExclusive) } -func verifySnapAndComponentSetupsForDownload(c *C, begin *state.Task, ts *state.TaskSet, downloadDir string) { +func verifySnapAndComponentSetupsForDownload(c *C, begin *state.Task, ts *state.TaskSet, downloadDir string, componentExclusive bool) { var snapsup snapstate.SnapSetup err := begin.Get("snap-setup", &snapsup) c.Assert(err, IsNil) @@ -10482,6 +10485,8 @@ func verifySnapAndComponentSetupsForDownload(c *C, begin *state.Task, ts *state. fmt.Sprintf("%s_%s.snap", snapsup.InstanceName(), snapsup.Revision()), )) + c.Assert(snapsup.ComponentExclusiveSetup, Equals, componentExclusive) + var compsupTaskIDs []string err = begin.Get("component-setup-tasks", &compsupTaskIDs) c.Assert(err, IsNil) diff --git a/tests/lib/assertions/test-snapd-component-remodel-initial-pc-24.json b/tests/lib/assertions/test-snapd-component-remodel-initial-pc-24.json new file mode 100644 index 00000000000..11582550a4e --- /dev/null +++ b/tests/lib/assertions/test-snapd-component-remodel-initial-pc-24.json @@ -0,0 +1,39 @@ +{ + "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" + }, + { + "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/assertions/test-snapd-component-remodel-new-pc-24.json b/tests/lib/assertions/test-snapd-component-remodel-new-pc-24.json new file mode 100644 index 00000000000..99ac7afbca2 --- /dev/null +++ b/tests/lib/assertions/test-snapd-component-remodel-new-pc-24.json @@ -0,0 +1,42 @@ +{ + "type": "model", + "authority-id": "developer1", + "series": "16", + "brand-id": "developer1", + "model": "my-model", + "revision": "2", + "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": "required" + } + }, + { + "default-channel": "latest/edge", + "id": "dwTAh7MZZ01zyriOZErqd1JynQLiOGvM", + "name": "core24", + "type": "base" + }, + { + "default-channel": "latest/edge", + "id": "PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4", + "name": "snapd", + "type": "snapd" + } + ] +} diff --git a/tests/nested/manual/remodel-with-components/task.yaml b/tests/nested/manual/remodel-with-components/task.yaml new file mode 100644 index 00000000000..ebe122662fd --- /dev/null +++ b/tests/nested/manual/remodel-with-components/task.yaml @@ -0,0 +1,126 @@ +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: + INITIAL_MODEL_JSON: $TESTSLIB/assertions/test-snapd-component-remodel-initial-pc-24.json + NEW_MODEL_JSON: $TESTSLIB/assertions/test-snapd-component-remodel-new-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 < "${INITIAL_MODEL_JSON}" > initial-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 initial-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 ./initial-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 + + gendeveloper1 sign-model < "${NEW_MODEL_JSON}" > new-model.assert + remote.push new-model.assert + + boot_id="$(tests.nested boot-id)" + change_id="$(remote.exec 'sudo snap remodel --no-wait new-model.assert')" + remote.wait-for reboot "${boot_id}" + + # this remodel expects two reboots, once for testing the recovery system + # and once for rebooting into the new kernel + boot_id="$(tests.nested boot-id)" + remote.wait-for reboot "${boot_id}" + + remote.exec "snap watch ${change_id}" + remote.exec 'snap list pc-kernel' | awk '$NR != 1 { print $3 }' | MATCH '2' + 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 from our + # component + remote.exec sudo modprobe mac80211_hwsim + remote.exec ip link show wlan0 + remote.exec modinfo --filename mac80211_hwsim | MATCH '/lib/modules/.*/updates/wifi-comp'