From c999099b23ef127b19d3e9e6312fcf101283c619 Mon Sep 17 00:00:00 2001 From: Andrew Phelps <136256549+andrewphelpsj@users.noreply.github.com> Date: Thu, 8 Aug 2024 09:02:15 -0400 Subject: [PATCH] o/snapstate, daemon: allow installing components for already installed snap (#14260) * o/snapstate: explicitly handle discard tasks in componentInstallTaskSet * o/snapstate: allow installing components for a snap that is already installed * daemon: move code around to create a place for installing components for already installed snaps * o/snapstate: add TODO:COMPS about using transaction * daemon: allow snapd API to install components from the store for already installed snaps * tests: spread test installing a component for a snap that is already installed * o/snapstate, store: do not return ErrNoUpdateAvailable for snaps that have had a resource revision change * daemon: lift variable initialization out of loop * daemon: rename some tests for clarity * o/snapstate: move function closer to similar functions * store: add comment explaining how we decide if a snap has no updates or not * o/snapstate: add TODO:COMPS about testing branch when losing comps during refresh * store: add doc comment on ResourceInstall field * o/snapstate, snap: add and use snap.AlreadyInstalledComponentError * o/snapstate: add tests for conflict behavior for installing components * o/snapstate: add params for splicing setup-profiles and setup-kernel-modules-components tasks into chain built by doInstallComponent * o/snapstate: manually add setup-profiles and setup-kernel-modules-components tasks into chain to avoid adding inaccurate data to the tasks * o/snapstate: add some clarifying comments * o/snapstate: fix typo in comment --- daemon/api.go | 2 +- daemon/api_aliases_test.go | 12 +- daemon/api_sideload_n_try_test.go | 8 +- daemon/api_snaps.go | 118 ++++--- daemon/api_snaps_test.go | 172 ++++++++-- daemon/export_test.go | 16 +- overlord/snapstate/component.go | 227 +++++++++++-- overlord/snapstate/component_install_test.go | 324 +++++++++++++++++-- overlord/snapstate/snapstate.go | 25 +- overlord/snapstate/snapstate_install_test.go | 2 +- overlord/snapstate/storehelpers.go | 17 +- overlord/snapstate/target.go | 16 +- snap/errors.go | 8 + store/store_action.go | 42 ++- store/store_action_test.go | 205 ++++++++++++ tests/main/component-from-store/task.yaml | 7 + 16 files changed, 1053 insertions(+), 148 deletions(-) diff --git a/daemon/api.go b/daemon/api.go index 2d3c2f63ce7..dd2af438106 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -139,11 +139,11 @@ func storeFrom(d *Daemon) snapstate.StoreService { var ( snapstateStoreInstallGoal = snapstate.StoreInstallGoal - snapstateInstallOne = snapstate.InstallOne snapstateInstallWithGoal = snapstate.InstallWithGoal snapstateInstallPath = snapstate.InstallPath snapstateInstallPathMany = snapstate.InstallPathMany snapstateInstallComponentPath = snapstate.InstallComponentPath + snapstateInstallComponents = snapstate.InstallComponents snapstateRefreshCandidates = snapstate.RefreshCandidates snapstateTryPath = snapstate.TryPath snapstateUpdate = snapstate.Update diff --git a/daemon/api_aliases_test.go b/daemon/api_aliases_test.go index c44c7deef44..464099dd8de 100644 --- a/daemon/api_aliases_test.go +++ b/daemon/api_aliases_test.go @@ -590,11 +590,11 @@ func (s *aliasesSuite) TestAliases(c *check.C) { func (s *aliasesSuite) TestInstallUnaliased(c *check.C) { var calledFlags snapstate.Flags - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -617,11 +617,11 @@ func (s *aliasesSuite) TestInstallUnaliased(c *check.C) { func (s *aliasesSuite) TestInstallIgnoreRunning(c *check.C) { var calledFlags snapstate.Flags - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -644,11 +644,11 @@ func (s *aliasesSuite) TestInstallIgnoreRunning(c *check.C) { func (s *aliasesSuite) TestInstallPrefer(c *check.C) { var calledFlags snapstate.Flags - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) diff --git a/daemon/api_sideload_n_try_test.go b/daemon/api_sideload_n_try_test.go index ff983bcd052..7d1d0a1bfa9 100644 --- a/daemon/api_sideload_n_try_test.go +++ b/daemon/api_sideload_n_try_test.go @@ -204,7 +204,7 @@ func (s *sideloadSuite) sideloadCheck(c *check.C, content string, head map[strin return &snap.Info{SuggestedName: mockedName}, nil })() - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -214,7 +214,7 @@ func (s *sideloadSuite) sideloadCheck(c *check.C, content string, head map[strin installQueue = append(installQueue, goal.snaps[0].InstanceName) t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() defer daemon.MockSnapstateInstallPath(func(s *state.State, si *snap.SideInfo, path, name, channel string, flags snapstate.Flags, prqt snapstate.PrereqTracker) (*state.TaskSet, *snap.Info, error) { @@ -1315,7 +1315,7 @@ func (s *trySuite) TestTrySnap(c *check.C) { return state.NewTaskSet(t), nil })() - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -1325,7 +1325,7 @@ func (s *trySuite) TestTrySnap(c *check.C) { } t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() // try the snap (without an installed core) diff --git a/daemon/api_snaps.go b/daemon/api_snaps.go index 18df6dca620..8c86658cab2 100644 --- a/daemon/api_snaps.go +++ b/daemon/api_snaps.go @@ -463,11 +463,6 @@ func snapInstall(ctx context.Context, inst *snapInstruction, st *state.State) (s return "", nil, fmt.Errorf(i18n.G("cannot install snap with empty name")) } - flags, err := inst.installFlags() - if err != nil { - return "", nil, err - } - var ckey string if inst.CohortKey == "" { logger.Noticef("Installing snap %q revision %s", inst.Snaps[0], inst.Revision) @@ -476,18 +471,12 @@ func snapInstall(ctx context.Context, inst *snapInstruction, st *state.State) (s logger.Noticef("Installing snap %q from cohort %q", inst.Snaps[0], ckey) } - // TODO:COMPS: handle installing a component of a snap that is already - // installed - goal := storeInstallGoalFromInstruction(inst) - _, tset, err := snapstateInstallOne(ctx, st, goal, snapstate.Options{ - UserID: inst.userID, - Flags: flags, - }) + _, tss, err := installationTaskSets(ctx, st, inst) if err != nil { return "", nil, err } - return installMessage(inst, ckey), []*state.TaskSet{tset}, nil + return installMessage(inst, ckey), tss, nil } func installMessage(inst *snapInstruction, cohort string) string { @@ -848,50 +837,97 @@ func (inst *snapInstruction) dispatchForMany() (op snapManyActionFunc) { return op } -func storeInstallGoalFromInstruction(inst *snapInstruction) snapstate.InstallGoal { - snaps := make([]snapstate.StoreSnap, 0, len(inst.Snaps)) - for _, sn := range inst.Snaps { - // we currently only allow revision options when installing one snap - opts := snapstate.RevisionOptions{} - if len(inst.Snaps) == 1 { - opts = *inst.revnoOpts() +func installationTaskSets(ctx context.Context, st *state.State, inst *snapInstruction) ([]*snap.Info, []*state.TaskSet, error) { + expectOneSnap := len(inst.Snaps) == 1 + opts := snapstate.Options{ + UserID: inst.userID, + ExpectOneSnap: expectOneSnap, + } + + if expectOneSnap { + flags, err := inst.installFlags() + if err != nil { + return nil, nil, err + } + opts.Flags = flags + } else { + opts.Flags.Transaction = inst.Transaction + } + + revOpts := snapstate.RevisionOptions{} + if expectOneSnap { + revOpts = *inst.revnoOpts() + } + + var ( + tss []*state.TaskSet + snaps []snapstate.StoreSnap + infos []*snap.Info + ) + for _, name := range inst.Snaps { + var snapst snapstate.SnapState + if err := snapstate.Get(st, name, &snapst); err != nil && !errors.Is(err, state.ErrNoState) { + return nil, nil, err + } + + comps := inst.CompsForSnaps[name] + + if snapst.IsInstalled() && len(comps) > 0 { + info, err := snapst.CurrentInfo() + if err != nil { + return nil, nil, err + } + + ts, err := snapstateInstallComponents(ctx, st, comps, info, opts) + if err != nil { + return nil, nil, err + } + + infos = append(infos, info) + tss = append(tss, ts...) + + continue } snaps = append(snaps, snapstate.StoreSnap{ - InstanceName: sn, - Components: inst.CompsForSnaps[sn], - RevOpts: opts, + InstanceName: name, + Components: comps, + RevOpts: revOpts, }) } - return snapstateStoreInstallGoal(snaps...) + // this means that we're installing a set of components for one snap that is + // already installed + if len(snaps) == 0 { + return infos, tss, nil + } + + installed, ts, err := snapstateInstallWithGoal(ctx, st, snapstateStoreInstallGoal(snaps...), opts) + if err != nil { + return nil, nil, err + } + + infos = append(infos, installed...) + tss = append(tss, ts...) + + return infos, tss, nil } func snapInstallMany(ctx context.Context, inst *snapInstruction, st *state.State) (*snapInstructionResult, error) { for _, name := range inst.Snaps { if len(name) == 0 { - return nil, fmt.Errorf(i18n.G("cannot install snap with empty name")) + return nil, errors.New(i18n.G("cannot install snap with empty name")) } } - // TODO:COMPS: handle installing a component of a snap that is already - // installed - goal := storeInstallGoalFromInstruction(inst) - installed, tasksets, err := snapstateInstallWithGoal(ctx, st, goal, snapstate.Options{ - UserID: inst.userID, - Flags: snapstate.Flags{ - Transaction: inst.Transaction, - }, - }) - if err != nil { - return nil, err - } - if len(inst.Snaps) == 0 { - return nil, fmt.Errorf("cannot install zero snaps") + return nil, errors.New(i18n.G("cannot install zero snaps")) } - msg := multiInstallMessage(inst) + installed, tasksets, err := installationTaskSets(ctx, st, inst) + if err != nil { + return nil, err + } names := make([]string, 0, len(installed)) for _, sn := range installed { @@ -899,7 +935,7 @@ func snapInstallMany(ctx context.Context, inst *snapInstruction, st *state.State } return &snapInstructionResult{ - Summary: msg, + Summary: multiInstallMessage(inst), Affected: names, Tasksets: tasksets, }, nil diff --git a/daemon/api_snaps_test.go b/daemon/api_snaps_test.go index 27be861405f..dba252feb93 100644 --- a/daemon/api_snaps_test.go +++ b/daemon/api_snaps_test.go @@ -1981,7 +1981,7 @@ func (s *snapsSuite) testPostSnap(c *check.C, extraJSON string, checkOpts func(o defer restore() checked := false - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -1991,7 +1991,7 @@ func (s *snapsSuite) testPostSnap(c *check.C, extraJSON string, checkOpts func(o } checked = true t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() var buf *bytes.Buffer @@ -2168,11 +2168,11 @@ func (s *snapsSuite) TestPostSnapSetsUser(c *check.C) { defer restore() checked := false - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { c.Check(opts.UserID, check.Equals, 1) checked = true t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() state := d.Overlord().State() @@ -2233,7 +2233,7 @@ func (s *snapsSuite) TestPostSnapOptionsUnsupportedAction(c *check.C) { func (s *snapsSuite) TestInstall(c *check.C) { var calledName string - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -2241,7 +2241,7 @@ func (s *snapsSuite) TestInstall(c *check.C) { calledName = goal.snaps[0].InstanceName t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -2263,11 +2263,11 @@ func (s *snapsSuite) TestInstall(c *check.C) { func (s *snapsSuite) TestInstallWithQuotaGroup(c *check.C) { var calledFlags snapstate.Flags - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -2288,11 +2288,11 @@ func (s *snapsSuite) TestInstallWithQuotaGroup(c *check.C) { func (s *snapsSuite) TestInstallDevMode(c *check.C) { var calledFlags snapstate.Flags - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -2315,11 +2315,11 @@ func (s *snapsSuite) TestInstallDevMode(c *check.C) { func (s *snapsSuite) TestInstallJailMode(c *check.C) { var calledFlags snapstate.Flags - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -2376,7 +2376,7 @@ func (s *snapsSuite) TestInstallCohort(c *check.C) { var calledName string var calledCohort string - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -2385,7 +2385,7 @@ func (s *snapsSuite) TestInstallCohort(c *check.C) { calledCohort = goal.snaps[0].RevOpts.CohortKey t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemon(c) @@ -2409,7 +2409,7 @@ func (s *snapsSuite) TestInstallIgnoreValidation(c *check.C) { var calledFlags snapstate.Flags installQueue := []string{} - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -2418,7 +2418,7 @@ func (s *snapsSuite) TestInstallIgnoreValidation(c *check.C) { calledFlags = opts.Flags t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() defer daemon.MockAssertstateRefreshSnapAssertions(func(s *state.State, userID int, opts *assertstate.RefreshAssertionsOptions) error { return nil @@ -2447,7 +2447,7 @@ func (s *snapsSuite) TestInstallIgnoreValidation(c *check.C) { } func (s *snapsSuite) TestInstallEmptyName(c *check.C) { - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { return nil, nil, errors.New("should not be called") })() @@ -2480,7 +2480,7 @@ func (s *snapsSuite) testInstall(c *check.C, forcedDevmode bool, flags snapstate restore := sandbox.MockForceDevMode(forcedDevmode) defer restore() - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -2490,7 +2490,7 @@ func (s *snapsSuite) testInstall(c *check.C, forcedDevmode bool, flags snapstate c.Check(revision, check.Equals, goal.snaps[0].RevOpts.Revision) t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemonWithFakeSnapManager(c) @@ -2527,10 +2527,10 @@ func (s *snapsSuite) testInstall(c *check.C, forcedDevmode bool, flags snapstate } func (s *snapsSuite) TestInstallUserAgentContextCreated(c *check.C) { - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { s.ctx = ctx t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() s.daemonWithFakeSnapManager(c) @@ -2549,9 +2549,9 @@ func (s *snapsSuite) TestInstallUserAgentContextCreated(c *check.C) { } func (s *snapsSuite) TestInstallFails(c *check.C) { - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { t := st.NewTask("fake-install-snap-error", "Install task") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemonWithFakeSnapManager(c) @@ -3883,7 +3883,7 @@ func (s *snapsSuite) TestPostComponentsManyRemoveCompsAndSnap(c *check.C) { } func (s *snapsSuite) TestInstallWithComponents(c *check.C) { - defer daemon.MockSnapstateInstallOne(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) (*snap.Info, *state.TaskSet, error) { + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { goal, ok := g.(*storeInstallGoalRecorder) c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) c.Assert(goal.snaps, check.HasLen, 1) @@ -3892,7 +3892,7 @@ func (s *snapsSuite) TestInstallWithComponents(c *check.C) { c.Check(goal.snaps[0].Components, check.DeepEquals, []string{"comp1", "comp2"}) t := st.NewTask("fake-install-snap", "Doing a fake install") - return &snap.Info{}, state.NewTaskSet(t), nil + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil })() d := s.daemonWithFakeSnapManager(c) @@ -3970,3 +3970,125 @@ func (s *snapsSuite) TestInstallManyWithComponents(c *check.C) { c.Check(chg.Kind(), check.Equals, "install-snap") c.Check(chg.Summary(), check.Equals, `Install snaps "some-snap" (with components "some-comp1", "some-comp2"), "other-snap" (with component "other-comp1")`) } + +func (s *snapsSuite) TestInstallWithComponentsSnapAlreadyInstalled(c *check.C) { + defer daemon.MockSnapstateInstallComponents(func(ctx context.Context, st *state.State, names []string, info *snap.Info, opts snapstate.Options) ([]*state.TaskSet, error) { + c.Check(names, check.DeepEquals, []string{"comp1", "comp2"}) + c.Check(info.InstanceName(), check.Equals, "some-snap") + t := st.NewTask("fake-install-component", "Doing a fake components install") + return []*state.TaskSet{state.NewTaskSet(t)}, nil + })() + + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { + c.Fatal("unexpected call to snapstateInstallWithGoal") + return nil, nil, nil + })() + + d := s.daemonWithFakeSnapManager(c) + + r := strings.NewReader(`{"action": "install", "components": ["comp1", "comp2"]}`) + req, err := http.NewRequest("POST", "/v2/snaps/some-snap", r) + c.Assert(err, check.IsNil) + + st := d.Overlord().State() + st.Lock() + si := &snap.SideInfo{ + RealName: "some-snap", + Revision: snap.R(1), + SnapID: "some-snap-id", + } + + snapstate.Set(st, "some-snap", &snapstate.SnapState{ + Active: true, + Sequence: snapstatetest.NewSequenceFromRevisionSideInfos( + []*sequence.RevisionSideState{sequence.NewRevisionSideState(si, nil)}, + ), + Current: snap.R(1), + }) + st.Unlock() + + rsp := s.asyncReq(c, req, nil) + + st.Lock() + + chg := st.Change(rsp.Change) + c.Assert(chg, check.NotNil) + + c.Check(chg.Tasks(), check.HasLen, 1) + + st.Unlock() + s.waitTrivialChange(c, chg) + st.Lock() + + c.Check(chg.Status(), check.Equals, state.DoneStatus) + c.Check(err, check.IsNil) + c.Check(chg.Kind(), check.Equals, "install-snap") + c.Check(chg.Summary(), check.Equals, `Install "some-snap" snap with components "comp1", "comp2"`) +} + +func (s *snapsSuite) TestManyInstallWithComponentsSnapAlreadyInstalled(c *check.C) { + defer daemon.MockSnapstateInstallComponents(func(ctx context.Context, st *state.State, names []string, info *snap.Info, opts snapstate.Options) ([]*state.TaskSet, error) { + c.Check(names, check.DeepEquals, []string{"comp1", "comp2"}) + c.Check(info.InstanceName(), check.Equals, "some-snap-with-components") + t := st.NewTask("fake-install-component", "Doing a fake components install") + return []*state.TaskSet{state.NewTaskSet(t)}, nil + })() + + defer daemon.MockSnapstateInstallWithGoal(func(ctx context.Context, st *state.State, g snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error) { + goal, ok := g.(*storeInstallGoalRecorder) + c.Assert(ok, check.Equals, true, check.Commentf("unexpected InstallGoal type %T", g)) + c.Assert(goal.snaps, check.HasLen, 1) + + c.Check(goal.snaps[0].InstanceName, check.Equals, "some-snap") + c.Check(goal.snaps[0].Components, check.HasLen, 0) + + t := st.NewTask("fake-install-snap", "Doing a fake install") + return []*snap.Info{{}}, []*state.TaskSet{state.NewTaskSet(t)}, nil + })() + + d := s.daemonWithFakeSnapManager(c) + + r := strings.NewReader(`{"action": "install", "snaps": ["some-snap", "some-snap-with-components"], "components": {"some-snap-with-components": ["comp1", "comp2"]}}`) + req, err := http.NewRequest("POST", "/v2/snaps", r) + c.Assert(err, check.IsNil) + req.Header.Set("Content-Type", "application/json") + + st := d.Overlord().State() + st.Lock() + si := &snap.SideInfo{ + RealName: "some-snap-with-components", + Revision: snap.R(1), + SnapID: "some-snap-id", + } + + snapstate.Set(st, "some-snap-with-components", &snapstate.SnapState{ + Active: true, + Sequence: snapstatetest.NewSequenceFromRevisionSideInfos( + []*sequence.RevisionSideState{sequence.NewRevisionSideState(si, nil)}, + ), + Current: snap.R(1), + }) + st.Unlock() + + rsp := s.asyncReq(c, req, nil) + + st.Lock() + + chg := st.Change(rsp.Change) + c.Assert(chg, check.NotNil) + + c.Check(chg.Tasks(), check.HasLen, 2) + + st.Unlock() + s.waitTrivialChange(c, chg) + st.Lock() + + c.Check(chg.Status(), check.Equals, state.DoneStatus) + c.Check(err, check.IsNil) + c.Check(chg.Kind(), check.Equals, "install-snap") + + // TODO: decide if we want to have a better summary that indicates that + // the component was installed for an already installed snap. more + // complicated code, but it could be nice to have. + c.Check(chg.Summary(), check.Equals, `Install snaps "some-snap", "some-snap-with-components" (with components "comp1", "comp2")`) +} diff --git a/daemon/export_test.go b/daemon/export_test.go index caf65ebeaab..b0735033bef 100644 --- a/daemon/export_test.go +++ b/daemon/export_test.go @@ -139,14 +139,6 @@ func MockAssertstateTryEnforceValidationSets(f func(st *state.State, validationS return r } -func MockSnapstateInstallOne(mock func(context.Context, *state.State, snapstate.InstallGoal, snapstate.Options) (*snap.Info, *state.TaskSet, error)) (restore func()) { - old := snapstateInstallOne - snapstateInstallOne = mock - return func() { - snapstateInstallOne = old - } -} - func MockSnapstateInstallWithGoal(mock func(ctx context.Context, st *state.State, goal snapstate.InstallGoal, opts snapstate.Options) ([]*snap.Info, []*state.TaskSet, error)) (restore func()) { old := snapstateInstallWithGoal snapstateInstallWithGoal = mock @@ -155,6 +147,14 @@ func MockSnapstateInstallWithGoal(mock func(ctx context.Context, st *state.State } } +func MockSnapstateInstallComponents(mock func(ctx context.Context, st *state.State, names []string, info *snap.Info, opts snapstate.Options) ([]*state.TaskSet, error)) (restore func()) { + old := snapstateInstallComponents + snapstateInstallComponents = mock + return func() { + snapstateInstallComponents = old + } +} + func MockSnapstateStoreInstallGoal(mock func(snaps ...snapstate.StoreSnap) snapstate.InstallGoal) (restore func()) { old := snapstateStoreInstallGoal snapstateStoreInstallGoal = mock diff --git a/overlord/snapstate/component.go b/overlord/snapstate/component.go index a5c5e185fe7..f38de407265 100644 --- a/overlord/snapstate/component.go +++ b/overlord/snapstate/component.go @@ -20,6 +20,7 @@ package snapstate import ( + "context" "errors" "fmt" "os" @@ -30,8 +31,171 @@ import ( "github.com/snapcore/snapd/overlord/state" "github.com/snapcore/snapd/snap" "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/store" ) +// InstallComponents installs all of the components in the given names list. The +// snap represented by info must already be installed, and all of the components +// in names should not be installed prior to calling this function. +// +// TODO:COMPS: respect the transaction that is passed to this function +func InstallComponents(ctx context.Context, st *state.State, names []string, info *snap.Info, opts Options) ([]*state.TaskSet, error) { + var snapst SnapState + err := Get(st, info.InstanceName(), &snapst) + if err != nil { + if errors.Is(err, state.ErrNoState) { + return nil, &snap.NotInstalledError{Snap: info.InstanceName()} + } + return nil, err + } + + for _, comp := range names { + if snapst.CurrentComponentSideInfo(naming.NewComponentRef(info.SnapName(), comp)) != nil { + return nil, snap.AlreadyInstalledComponentError{Component: comp} + } + } + + compsups, err := componentSetupsForInstall(ctx, st, names, info, opts) + if err != nil { + return nil, err + } + + 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, + } + + setupSecurity := st.NewTask("setup-profiles", + fmt.Sprintf(i18n.G("Setup snap %q (%s) security profiles"), info.InstanceName(), info.Revision)) + setupSecurity.Set("snap-setup", snapsup) + + var kmodSetup *state.Task + if requiresKmodSetup(&snapst, compsups) { + kmodSetup = st.NewTask("prepare-kernel-modules-components", fmt.Sprintf( + i18n.G("Prepare kernel-modules components for %q%s"), info.InstanceName(), info.Revision, + )) + } + + tss := make([]*state.TaskSet, 0, len(compsups)) + compSetupIDs := make([]string, 0, len(compsups)) + for _, compsup := range compsups { + // since we are installing multiple components, we don't want to setup + // the security profiles until the end + compsup.MultiComponentInstall = true + + // here we share the setupSecurity and kmodSetup tasks between all of + // the component task chains. this results in multiple parallel tasks + // (one per copmonent) that have synchronization points at the + // setupSecurity and kmodSetup tasks. + componentTS, err := doInstallComponent(st, &snapst, compsup, snapsup, setupSecurity.ID(), setupSecurity, kmodSetup, opts.FromChange) + if err != nil { + return nil, err + } + + compSetupIDs = append(compSetupIDs, componentTS.compSetupTaskID) + tss = append(tss, componentTS.taskSet()) + } + + setupSecurity.Set("component-setup-tasks", compSetupIDs) + + ts := state.NewTaskSet(setupSecurity) + if kmodSetup != nil { + ts.AddTask(kmodSetup) + } + return append(tss, ts), nil +} + +func componentSetupsForInstall(ctx context.Context, st *state.State, names []string, info *snap.Info, opts Options) ([]ComponentSetup, error) { + var snapst SnapState + err := Get(st, info.InstanceName(), &snapst) + if err != nil { + if errors.Is(err, state.ErrNoState) { + return nil, &snap.NotInstalledError{Snap: info.InstanceName()} + } + return nil, err + } + + current, err := currentSnaps(st) + if err != nil { + return nil, err + } + + // TODO:COMPS: figure out which user to use here + user, err := userFromUserID(st, opts.UserID) + if err != nil { + return nil, err + } + + action, err := installComponentAction(st, snapst, opts) + if err != nil { + return nil, err + } + + refreshOpts, err := refreshOptions(st, &store.RefreshOptions{ + IncludeResources: true, + }) + if err != nil { + return nil, err + } + + sto := Store(st, opts.DeviceCtx) + st.Unlock() + sars, _, err := sto.SnapAction(ctx, current, []*store.SnapAction{action}, nil, user, refreshOpts) + st.Lock() + if err != nil { + return nil, err + } + + if len(sars) != 1 { + return nil, fmt.Errorf("internal error: expected exactly one snap action result, got %d", len(sars)) + } + + return componentTargetsFromActionResult("install", sars[0], names) +} + +func installComponentAction(st *state.State, snapst SnapState, opts Options) (*store.SnapAction, error) { + si := snapst.CurrentSideInfo() + if si == nil { + return nil, errors.New("internal error: cannot install components for a snap that is not installed") + } + + if si.SnapID == "" { + return nil, errors.New("internal error: cannot install components for a snap that is unknown to the store") + } + + enforcedSetsFunc := cachedEnforcedValidationSets(st) + + // we send a refresh action, since that is what the store requested that + // we do in this case + action := &store.SnapAction{ + Action: "refresh", + SnapID: si.SnapID, + InstanceName: snapst.InstanceName(), + ResourceInstall: true, + } + + // we send an action that contains the current channel and revision so + // that we make sure to get back components that are compatible with the + // currently installed snap + revOpts := RevisionOptions{ + Channel: snapst.TrackingChannel, + Revision: snapst.Current, + } + + // TODO:COMPS: handle validation sets here + if err := completeStoreAction(action, revOpts, opts.Flags.IgnoreValidation, enforcedSetsFunc); err != nil { + return nil, err + } + + return action, nil +} + // InstallComponentPath returns a set of tasks for installing a snap component // from a file path. // @@ -78,7 +242,7 @@ func InstallComponentPath(st *state.State, csi *snap.ComponentSideInfo, info *sn }, } - componentTS, err := doInstallComponent(st, &snapst, compSetup, snapsup, "", "") + componentTS, err := doInstallComponent(st, &snapst, compSetup, snapsup, "", nil, nil, "") if err != nil { return nil, err } @@ -87,32 +251,40 @@ func InstallComponentPath(st *state.State, csi *snap.ComponentSideInfo, info *sn } type componentInstallFlags struct { - RemoveComponentPath bool `json:"remove-component-path,omitempty"` - JointSnapComponentsInstall bool `json:"joint-snap-components-install,omitempty"` + RemoveComponentPath bool `json:"remove-component-path,omitempty"` + MultiComponentInstall bool `json:"joint-snap-components-install,omitempty"` } type componentInstallTaskSet struct { - compSetupTask *state.Task + compSetupTaskID string beforeLink []*state.Task - linkToHook []*state.Task + linkTask *state.Task postOpHookAndAfter []*state.Task + discardTask *state.Task } func (c *componentInstallTaskSet) taskSet() *state.TaskSet { - tasks := make([]*state.Task, 0, len(c.beforeLink)+len(c.linkToHook)+len(c.postOpHookAndAfter)) + tasks := make([]*state.Task, 0, len(c.beforeLink)+1+len(c.postOpHookAndAfter)+1) tasks = append(tasks, c.beforeLink...) - tasks = append(tasks, c.linkToHook...) + tasks = append(tasks, c.linkTask) tasks = append(tasks, c.postOpHookAndAfter...) + if c.discardTask != nil { + tasks = append(tasks, c.discardTask) + } ts := state.NewTaskSet(tasks...) - ts.MarkEdge(c.compSetupTask, BeginEdge) + for _, t := range ts.Tasks() { + if t.ID() == c.compSetupTaskID { + ts.MarkEdge(t, BeginEdge) + } + } return ts } // doInstallComponent might be called with the owner snap installed or not. func doInstallComponent(st *state.State, snapst *SnapState, compSetup ComponentSetup, - snapsup SnapSetup, snapSetupTaskID string, fromChange string) (componentInstallTaskSet, error) { + snapsup SnapSetup, snapSetupTaskID string, setupSecurity, kmodSetup *state.Task, fromChange string) (componentInstallTaskSet, error) { // TODO check for experimental flag that will hide temporarily components @@ -151,19 +323,20 @@ func doInstallComponent(st *state.State, snapst *SnapState, compSetup ComponentS if snapSetupTaskID != "" { prepare.Set("snap-setup-task", snapSetupTaskID) } else { + snapSetupTaskID = prepare.ID() prepare.Set("snap-setup", snapsup) } prev := prepare addTask := func(t *state.Task) { t.Set("component-setup-task", prepare.ID()) - t.Set("snap-setup-task", prepare.ID()) + t.Set("snap-setup-task", snapSetupTaskID) t.WaitFor(prev) prev = t } componentTS := componentInstallTaskSet{ - compSetupTask: prepare, + compSetupTaskID: prepare.ID(), } componentTS.beforeLink = append(componentTS.beforeLink, prepare) @@ -222,17 +395,25 @@ func doInstallComponent(st *state.State, snapst *SnapState, compSetup ComponentS } // security - if !compSetup.JointSnapComponentsInstall { - setupSecurity := st.NewTask("setup-profiles", fmt.Sprintf(i18n.G("Setup component %q%s security profiles"), compSi.Component, revisionStr)) + if !compSetup.MultiComponentInstall && setupSecurity == nil { + setupSecurity = st.NewTask("setup-profiles", fmt.Sprintf(i18n.G("Setup component %q%s security profiles"), compSi.Component, revisionStr)) + setupSecurity.Set("component-setup-task", prepare.ID()) + setupSecurity.Set("snap-setup-task", snapSetupTaskID) componentTS.beforeLink = append(componentTS.beforeLink, setupSecurity) - addTask(setupSecurity) + } + if setupSecurity != nil { + // note that we don't use addTask here because this task is shared and + // we don't want to add "component-setup-task" or "snap-setup-task" to + // it + setupSecurity.WaitFor(prev) + prev = setupSecurity } // finalize (sets SnapState) linkSnap := st.NewTask("link-component", fmt.Sprintf(i18n.G("Make component %q%s available to the system"), compSi.Component, revisionStr)) - componentTS.linkToHook = append(componentTS.linkToHook, linkSnap) + componentTS.linkTask = linkSnap addTask(linkSnap) var postOpHook *state.Task @@ -244,12 +425,20 @@ func doInstallComponent(st *state.State, snapst *SnapState, compSetup ComponentS componentTS.postOpHookAndAfter = append(componentTS.postOpHookAndAfter, postOpHook) addTask(postOpHook) - if !compSetup.JointSnapComponentsInstall && compSetup.CompType == snap.KernelModulesComponent { - kmodSetup := st.NewTask("prepare-kernel-modules-components", + if !compSetup.MultiComponentInstall && kmodSetup == nil && compSetup.CompType == snap.KernelModulesComponent { + kmodSetup = st.NewTask("prepare-kernel-modules-components", fmt.Sprintf(i18n.G("Prepare kernel-modules component %q%s"), compSi.Component, revisionStr)) + kmodSetup.Set("component-setup-task", prepare.ID()) + kmodSetup.Set("snap-setup-task", snapSetupTaskID) componentTS.postOpHookAndAfter = append(componentTS.postOpHookAndAfter, kmodSetup) - addTask(kmodSetup) + } + if kmodSetup != nil { + // note that we don't use addTask here because this task is shared and + // we don't want to add "component-setup-task" or "snap-setup-task" to + // it + kmodSetup.WaitFor(prev) + prev = kmodSetup } changingComponentRev := false @@ -269,7 +458,7 @@ func doInstallComponent(st *state.State, snapst *SnapState, compSetup ComponentS discardComp := st.NewTask("discard-component", fmt.Sprintf(i18n.G( "Discard previous revision for component %q"), compSi.Component)) - componentTS.postOpHookAndAfter = append(componentTS.postOpHookAndAfter, discardComp) + componentTS.discardTask = discardComp addTask(discardComp) } diff --git a/overlord/snapstate/component_install_test.go b/overlord/snapstate/component_install_test.go index bd8cbda1db6..b961e09b8be 100644 --- a/overlord/snapstate/component_install_test.go +++ b/overlord/snapstate/component_install_test.go @@ -20,7 +20,10 @@ package snapstate_test import ( + "bytes" + "context" "fmt" + "strings" "github.com/snapcore/snapd/osutil" "github.com/snapcore/snapd/overlord/snapstate" @@ -31,6 +34,8 @@ import ( "github.com/snapcore/snapd/snap/naming" "github.com/snapcore/snapd/snap/snapfile" "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/store" + "github.com/snapcore/snapd/testutil" . "gopkg.in/check.v1" ) @@ -45,12 +50,11 @@ const ( compTypeIsKernMods // Current component is discarded at the end compCurrentIsDiscarded - // Component is being installed with a snap, so skip setup-profiles - compOptSkipSecurity + // Component is being installed with a snap, so skip setup-profiles and + // prepare-kernel-modules-components + compOptMultiCompInstall // Component is being installed with a snap that is being refreshed compOptDuringSnapRefresh - // Component is being installed with a snap, so skip prepare-kernel-modules-components - compOptSkipKernelModulesSetup ) // opts is a bitset with compOpt* as possible values. @@ -80,7 +84,7 @@ func expectedComponentInstallTasksSplit(opts int) (beforeLink []string, linkToHo beforeLink = append(beforeLink, "unlink-current-component") } - if opts&compOptSkipSecurity == 0 { + if opts&compOptMultiCompInstall == 0 { beforeLink = append(beforeLink, "setup-profiles") } @@ -94,7 +98,7 @@ func expectedComponentInstallTasksSplit(opts int) (beforeLink []string, linkToHo postOpHooksAndAfter = []string{"run-hook[post-refresh]"} } - if opts&compTypeIsKernMods != 0 && opts&compOptSkipKernelModulesSetup == 0 { + if opts&compTypeIsKernMods != 0 && opts&compOptMultiCompInstall == 0 { postOpHooksAndAfter = append(postOpHooksAndAfter, "prepare-kernel-modules-components") } @@ -107,7 +111,7 @@ func expectedComponentInstallTasksSplit(opts int) (beforeLink []string, linkToHo func checkSetupTasks(c *C, ts *state.TaskSet) { // Check presence of snap setup / component setup in the tasks - var firstTaskID string + var firstTaskID, snapSetupTaskID string var compSetup snapstate.ComponentSetup var snapsup snapstate.SnapSetup for i, t := range ts.Tasks() { @@ -119,14 +123,21 @@ func checkSetupTasks(c *C, ts *state.TaskSet) { chg.AddAll(ts) } c.Assert(t.Get("component-setup", &compSetup), IsNil) - c.Assert(t.Get("snap-setup", &snapsup), IsNil) + sn, err := snapstate.TaskSnapSetup(t) + c.Assert(err, IsNil) + snapsup = *sn firstTaskID = t.ID() + if t.Has("snap-setup") { + snapSetupTaskID = t.ID() + } else { + t.Get("snap-setup-task", &snapSetupTaskID) + } default: var storedTaskID string c.Assert(t.Get("component-setup-task", &storedTaskID), IsNil) c.Assert(storedTaskID, Equals, firstTaskID) c.Assert(t.Get("snap-setup-task", &storedTaskID), IsNil) - c.Assert(storedTaskID, Equals, firstTaskID) + c.Assert(storedTaskID, Equals, snapSetupTaskID) } // ComponentSetup/SnapSetup found must match the ones from the first task csup, ssup, err := snapstate.TaskComponentSetup(t) @@ -167,22 +178,29 @@ version: 1.0 } func createTestSnapInfoForComponent(c *C, snapName string, snapRev snap.Revision, compName string) *snap.Info { - return createTestSnapInfoForComponentWithType(c, snapName, snapRev, compName, "test") + return createTestSnapInfoForComponents(c, snapName, snapRev, map[string]string{compName: "test"}) } -func createTestSnapInfoForComponentWithType(c *C, snapName string, snapRev snap.Revision, compName, compType string) *snap.Info { +func createTestSnapInfoForComponents(c *C, snapName string, snapRev snap.Revision, compNamesToType map[string]string) *snap.Info { snapType := "app" - if compType == "kernel-modules" { - snapType = "kernel" + for _, typ := range compNamesToType { + if typ == "kernel-modules" { + snapType = "kernel" + } } - snapYaml := fmt.Sprintf(`name: %s + + var b bytes.Buffer + fmt.Fprintf(&b, `name: %s type: %s version: 1.1 components: - %s: - type: %s -`, snapName, snapType, compName, compType) - info, err := snap.InfoFromSnapYaml([]byte(snapYaml)) +`, snapName, snapType) + + for compName, typ := range compNamesToType { + fmt.Fprintf(&b, " %s:\n type: %s\n", compName, typ) + } + + info, err := snap.InfoFromSnapYaml(b.Bytes()) c.Assert(err, IsNil) info.SideInfo = snap.SideInfo{RealName: snapName, Revision: snapRev} @@ -210,7 +228,8 @@ func setStateWithOneSnap(st *state.State, snapName string, snapRev snap.Revision Sequence: snapstatetest.NewSequenceFromRevisionSideInfos( []*sequence.RevisionSideState{ sequence.NewRevisionSideState(ssi, nil)}), - Current: snapRev, + Current: snapRev, + TrackingChannel: "channel-for-components", }) } @@ -494,7 +513,7 @@ func (s *snapmgrTestSuite) TestInstallComponentPathSnapNotActive(c *C) { c.Assert(osutil.FileExists(compPath), Equals, true) } -func (s *snapmgrTestSuite) TestInstallComponentRemodelConflict(c *C) { +func (s *snapmgrTestSuite) TestInstallComponentPathRemodelConflict(c *C) { const snapName = "mysnap" const compName = "mycomp" snapRev := snap.R(1) @@ -519,7 +538,7 @@ func (s *snapmgrTestSuite) TestInstallComponentRemodelConflict(c *C) { `remodeling in progress, no other changes allowed until this is done`) } -func (s *snapmgrTestSuite) TestInstallComponentUpdateConflict(c *C) { +func (s *snapmgrTestSuite) TestInstallComponentPathUpdateConflict(c *C) { const snapName = "some-snap" const compName = "mycomp" snapRev := snap.R(1) @@ -547,11 +566,131 @@ func (s *snapmgrTestSuite) TestInstallComponentUpdateConflict(c *C) { `snap "some-snap" has "update" change in progress`) } +func (s *snapmgrTestSuite) TestInstallComponentUpdateConflict(c *C) { + const snapName = "some-snap" + const compName = "test-component" + snapRev := snap.R(1) + info := createTestSnapInfoForComponent(c, snapName, snapRev, compName) + + s.state.Lock() + defer s.state.Unlock() + + setStateWithOneSnap(s.state, snapName, snapRev) + + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + return []store.SnapResourceResult{{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + compName, + }, + Name: compName, + Revision: 3, + Type: "component/test", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }} + } + + tupd, err := snapstate.Update(s.state, snapName, + &snapstate.RevisionOptions{Channel: ""}, s.user.ID, + snapstate.Flags{}) + c.Assert(err, IsNil) + chg := s.state.NewChange("update", "update a snap") + chg.AddAll(tupd) + + _, err = snapstate.InstallComponents(context.TODO(), s.state, []string{compName}, info, snapstate.Options{}) + c.Assert(err.Error(), Equals, `snap "some-snap" has "update" change in progress`) +} + +func (s *snapmgrTestSuite) TestInstallComponentConflictsWithSelf(c *C) { + const ( + snapName = "some-snap" + compName = "test-component" + conflictComponentName = "kernel-modules-component" + ) + + typeMapping := map[string]string{ + compName: "test", + conflictComponentName: "kernel-modules", + } + + snapRev := snap.R(1) + info := createTestSnapInfoForComponents(c, snapName, snapRev, typeMapping) + + s.state.Lock() + defer s.state.Unlock() + + setStateWithOneSnap(s.state, snapName, snapRev) + + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + results := make([]store.SnapResourceResult, 0, 2) + for _, name := range []string{compName, conflictComponentName} { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + name, + }, + Name: name, + Revision: 3, + Type: "component/" + typeMapping[name], + Version: "1.0", + }) + } + return results + } + + tss, err := snapstate.InstallComponents(context.TODO(), s.state, []string{compName}, info, snapstate.Options{}) + c.Assert(err, IsNil) + chg := s.state.NewChange("install-component", "install a component") + for _, ts := range tss { + chg.AddAll(ts) + } + + _, err = snapstate.InstallComponents(context.TODO(), s.state, []string{conflictComponentName}, info, snapstate.Options{}) + c.Assert(err.Error(), Equals, `snap "some-snap" has "install-component" change in progress`) +} + +func (s *snapmgrTestSuite) TestInstallComponentCausesConflict(c *C) { + const ( + snapName = "some-snap" + compName = "test-component" + ) + + snapRev := snap.R(1) + info := createTestSnapInfoForComponent(c, snapName, snapRev, compName) + + s.state.Lock() + defer s.state.Unlock() + + setStateWithOneSnap(s.state, snapName, snapRev) + + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + return []store.SnapResourceResult{{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + compName, + }, + Name: compName, + Revision: 3, + Type: "component/test", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }} + } + + tss, err := snapstate.InstallComponents(context.TODO(), s.state, []string{compName}, info, snapstate.Options{}) + c.Assert(err, IsNil) + chg := s.state.NewChange("install-component", "install a component") + for _, ts := range tss { + chg.AddAll(ts) + } + + _, err = snapstate.Update(s.state, snapName, nil, s.user.ID, snapstate.Flags{}) + c.Assert(err.Error(), Equals, `snap "some-snap" has "install-component" change in progress`) +} + func (s *snapmgrTestSuite) TestInstallKernelModulesComponentPath(c *C) { const snapName = "mysnap" const compName = "mycomp" snapRev := snap.R(1) - info := createTestSnapInfoForComponentWithType(c, snapName, snapRev, compName, "kernel-modules") + info := createTestSnapInfoForComponents(c, snapName, snapRev, map[string]string{compName: "kernel-modules"}) _, compPath := createTestComponentWithType(c, snapName, compName, "kernel-modules", info) s.state.Lock() @@ -654,3 +793,144 @@ func (s *snapmgrTestSuite) TestInstallComponentPathRun(c *C) { c.Assert(snapst.IsComponentInCurrentSeq(cref), Equals, true) } + +func (s *snapmgrTestSuite) TestInstallComponents(c *C) { + const snapName = "some-snap" + snapRev := snap.R(1) + + compNamesToType := map[string]string{ + "one": "test", + "two": "test", + } + + info := createTestSnapInfoForComponents(c, snapName, snapRev, compNamesToType) + + s.state.Lock() + defer s.state.Unlock() + + si := &snap.SideInfo{ + RealName: snapName, + Revision: snapRev, + SnapID: "some-snap-id", + } + + snapstate.Set(s.state, snapName, &snapstate.SnapState{ + Active: true, + Sequence: snapstatetest.NewSequenceFromRevisionSideInfos([]*sequence.RevisionSideState{ + sequence.NewRevisionSideState(si, nil), + }), + Current: snapRev, + TrackingChannel: "channel-for-components", + }) + + components := []string{"test-component", "kernel-modules-component"} + + compNameToType := func(name string) snap.ComponentType { + typ := strings.TrimSuffix(name, "-component") + if typ == name { + c.Fatalf("unexpected component name %q", name) + } + return snap.ComponentType(typ) + } + + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + c.Assert(info.InstanceName(), DeepEquals, snapName) + var results []store.SnapResourceResult + for _, compName := range components { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + compName, + }, + Name: compName, + Revision: snap.R(3).N, + Type: fmt.Sprintf("component/%s", compNameToType(compName)), + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }) + } + return results + } + + tss, err := snapstate.InstallComponents(context.Background(), s.state, components, info, snapstate.Options{}) + c.Assert(err, IsNil) + + setupProfiles := tss[len(tss)-1].Tasks()[0] + c.Assert(setupProfiles.Kind(), Equals, "setup-profiles") + + prepareKmodComps := tss[len(tss)-1].Tasks()[1] + c.Assert(prepareKmodComps.Kind(), Equals, "prepare-kernel-modules-components") + + // add to change so that we can use TaskComponentSetup + chg := s.state.NewChange("install", "...") + for _, ts := range tss { + chg.AddAll(ts) + } + + for _, ts := range tss[0 : len(tss)-1] { + task := ts.Tasks()[0] + compsup, _, err := snapstate.TaskComponentSetup(task) + c.Assert(err, IsNil) + + opts := compOptMultiCompInstall + if compNameToType(compsup.ComponentName()) == snap.KernelModulesComponent { + opts |= compTypeIsKernMods + } + + verifyComponentInstallTasks(c, opts, ts) + + linkTasks := tasksWithKind(ts, "link-component") + c.Assert(linkTasks, HasLen, 1) + + // make sure that the link-component tasks wait on the all-inclusive + // setup-profiles task + c.Assert(linkTasks[0].WaitTasks(), testutil.DeepContains, setupProfiles) + + installHook := tasksWithKind(ts, "run-hook") + c.Assert(installHook, HasLen, 1) + + // make sure that the run-hook[install] tasks wait on the all-inclusive + // prepare-kernel-modules-components task + c.Assert(prepareKmodComps.WaitTasks(), testutil.DeepContains, installHook[0]) + } +} + +func (s *snapmgrTestSuite) TestInstallComponentsAlreadyInstalledError(c *C) { + const snapName = "some-snap" + snapRev := snap.R(1) + + compNamesToType := map[string]string{ + "one": "test", + "two": "test", + } + + info := createTestSnapInfoForComponents(c, snapName, snapRev, compNamesToType) + + s.state.Lock() + defer s.state.Unlock() + + si := &snap.SideInfo{ + RealName: snapName, + Revision: snapRev, + SnapID: "some-snap-id", + } + + seq := snapstatetest.NewSequenceFromRevisionSideInfos([]*sequence.RevisionSideState{ + sequence.NewRevisionSideState(si, nil), + }) + + seq.AddComponentForRevision(snapRev, sequence.NewComponentState(&snap.ComponentSideInfo{ + Component: naming.NewComponentRef(snapName, "one"), + Revision: snap.R(1), + }, snap.TestComponent)) + + snapstate.Set(s.state, snapName, &snapstate.SnapState{ + Active: true, + Sequence: seq, + Current: snapRev, + TrackingChannel: "channel-for-components", + }) + + _, err := snapstate.InstallComponents(context.TODO(), s.state, []string{"one", "two"}, info, snapstate.Options{}) + + c.Assert(err, testutil.ErrorIs, snap.AlreadyInstalledComponentError{Component: "one"}) +} diff --git a/overlord/snapstate/snapstate.go b/overlord/snapstate/snapstate.go index cdf0102e621..a3434c0f7dd 100644 --- a/overlord/snapstate/snapstate.go +++ b/overlord/snapstate/snapstate.go @@ -436,7 +436,7 @@ func doInstall(st *state.State, snapst *SnapState, snapsup SnapSetup, compsups [ } } - tasksBeforePreRefreshHook, tasksAfterLinkSnap, tasksAfterPostOpHook, compSetupIDs, err := splitComponentTasksForInstall( + tasksBeforePreRefreshHook, tasksAfterLinkSnap, tasksAfterPostOpHook, tasksBeforeDiscard, compSetupIDs, err := splitComponentTasksForInstall( compsups, st, snapst, snapsup, prepare.ID(), fromChange, ) if err != nil { @@ -614,6 +614,12 @@ func doInstall(st *state.State, snapst *SnapState, snapsup SnapSetup, compsups [ startSnapServices := st.NewTask("start-snap-services", fmt.Sprintf(i18n.G("Start snap %q%s services"), snapsup.InstanceName(), revisionStr)) addTask(startSnapServices) + // TODO:COMPS: test discarding components during a snap refresh (coming + // soon!) + for _, t := range tasksBeforeDiscard { + addTask(t) + } + // Do not do that if we are reverting to a local revision var cleanupTask *state.Task if snapst.IsInstalled() && !snapsup.Flags.Revert { @@ -750,23 +756,26 @@ func splitComponentTasksForInstall( snapSetupTaskID string, fromChange string, ) ( - tasksBeforePreRefreshHook, tasksAfterLinkSnap, tasksAfterPostOpHook []*state.Task, + tasksBeforePreRefreshHook, tasksAfterLinkSnap, tasksAfterPostOpHook, tasksBeforeDiscard []*state.Task, compSetupIDs []string, err error, ) { for _, compsup := range compsups { - componentTS, err := doInstallComponent(st, snapst, compsup, snapsup, snapSetupTaskID, fromChange) + componentTS, err := doInstallComponent(st, snapst, compsup, snapsup, snapSetupTaskID, nil, nil, fromChange) if err != nil { - return nil, nil, nil, nil, fmt.Errorf("cannot install component %q: %v", compsup.CompSideInfo.Component, err) + return nil, nil, nil, nil, nil, fmt.Errorf("cannot install component %q: %v", compsup.CompSideInfo.Component, err) } - compSetupIDs = append(compSetupIDs, componentTS.compSetupTask.ID()) + compSetupIDs = append(compSetupIDs, componentTS.compSetupTaskID) tasksBeforePreRefreshHook = append(tasksBeforePreRefreshHook, componentTS.beforeLink...) - tasksAfterLinkSnap = append(tasksAfterLinkSnap, componentTS.linkToHook...) + tasksAfterLinkSnap = append(tasksAfterLinkSnap, componentTS.linkTask) tasksAfterPostOpHook = append(tasksAfterPostOpHook, componentTS.postOpHookAndAfter...) + if componentTS.discardTask != nil { + tasksBeforeDiscard = append(tasksBeforeDiscard, componentTS.discardTask) + } } - return tasksBeforePreRefreshHook, tasksAfterLinkSnap, tasksAfterPostOpHook, compSetupIDs, nil + return tasksBeforePreRefreshHook, tasksAfterLinkSnap, tasksAfterPostOpHook, tasksBeforeDiscard, compSetupIDs, nil } func NeedsKernelSetup(model *asserts.Model) bool { @@ -3626,7 +3635,7 @@ func removeInactiveRevision(st *state.State, snapst *SnapState, name, snapID str CompSideInfo: &cinfo.ComponentSideInfo, CompType: cinfo.Type, componentInstallFlags: componentInstallFlags{ - JointSnapComponentsInstall: true, + MultiComponentInstall: true, }, } diff --git a/overlord/snapstate/snapstate_install_test.go b/overlord/snapstate/snapstate_install_test.go index 29155ffad75..22336de86a5 100644 --- a/overlord/snapstate/snapstate_install_test.go +++ b/overlord/snapstate/snapstate_install_test.go @@ -110,7 +110,7 @@ func expectedDoInstallTasks(typ snap.Type, opts, discards int, startTasks []stri var tasksBeforeCurrentUnlink, tasksAfterLinkSnap, tasksAfterPostOpHook []string for range components { - compOpts := compOptSkipSecurity | compOptSkipKernelModulesSetup + compOpts := compOptMultiCompInstall if opts&localSnap != 0 { compOpts |= compOptIsLocal } diff --git a/overlord/snapstate/storehelpers.go b/overlord/snapstate/storehelpers.go index e36cd3ea23a..68a3c860655 100644 --- a/overlord/snapstate/storehelpers.go +++ b/overlord/snapstate/storehelpers.go @@ -62,6 +62,13 @@ func userIDForSnap(st *state.State, snapst *SnapState, fallbackUserID int) (int, return fallbackUserID, nil } +func fallbackUserID(user *auth.UserState) int { + if !user.HasStoreAuth() { + return 0 + } + return user.ID +} + // userFromUserID returns the first valid user from a series of userIDs // used as successive fallbacks. func userFromUserID(st *state.State, userIDs ...int) (*auth.UserState, error) { @@ -611,13 +618,7 @@ func storeUpdatePlanCore( enforcedSetsFunc := cachedEnforcedValidationSets(st) - var fallbackID int - // normalize fallback user - if !user.HasStoreAuth() { - user = nil - } else { - fallbackID = user.ID - } + fallbackID := fallbackUserID(user) // hasLocalRevision keeps track of snaps that already have a local revision // matching the requested revision. there are two distinct cases here: @@ -686,7 +687,7 @@ func storeUpdatePlanCore( // TODO:COMPS: handle components losing a resource that is currently // installed - compTargets, err := componentTargetsFromActionResult(sar, compNames) + compTargets, err := componentTargetsFromActionResult("refresh", sar, compNames) if err != nil { return updatePlan{}, fmt.Errorf("cannot extract components from snap resources: %w", err) } diff --git a/overlord/snapstate/target.go b/overlord/snapstate/target.go index 94361e7a398..adfce203261 100644 --- a/overlord/snapstate/target.go +++ b/overlord/snapstate/target.go @@ -112,8 +112,8 @@ func (t *target) setups(st *state.State, opts Options) (SnapSetup, []ComponentSe componentInstallFlags: componentInstallFlags{ // if we're removing the snap, then we should remove the // components too - RemoveComponentPath: flags.RemoveSnapPath, - JointSnapComponentsInstall: true, + RemoveComponentPath: flags.RemoveSnapPath, + MultiComponentInstall: true, }, }) } @@ -321,7 +321,7 @@ func (s *storeInstallGoal) toInstall(ctx context.Context, st *state.State, opts channel = "stable" } - comps, err := componentTargetsFromActionResult(r, sn.Components) + comps, err := componentTargetsFromActionResult("install", r, sn.Components) if err != nil { return nil, fmt.Errorf("cannot extract components from snap resources: %w", err) } @@ -360,7 +360,7 @@ func cachedEnforcedValidationSets(st *state.State) func() (*snapasserts.Validati } } -func componentTargetsFromActionResult(sar store.SnapActionResult, requested []string) ([]ComponentSetup, error) { +func componentTargetsFromActionResult(action string, sar store.SnapActionResult, requested []string) ([]ComponentSetup, error) { mapping := make(map[string]store.SnapResourceResult, len(sar.Resources)) for _, res := range sar.Resources { mapping[res.Name] = res @@ -370,6 +370,14 @@ func componentTargetsFromActionResult(sar store.SnapActionResult, requested []st for _, comp := range requested { res, ok := mapping[comp] if !ok { + // TODO:COMPS: make sure this branch is tested when we add support for + // losing components during a refresh + // during a refresh, we will not install components that don't exist + // in the new revision + if action == "refresh" { + continue + } + return nil, fmt.Errorf("cannot find component %q in snap resources", comp) } diff --git a/snap/errors.go b/snap/errors.go index 9423a646443..359aef19efe 100644 --- a/snap/errors.go +++ b/snap/errors.go @@ -31,6 +31,14 @@ func (e AlreadyInstalledError) Error() string { return fmt.Sprintf("snap %q is already installed", e.Snap) } +type AlreadyInstalledComponentError struct { + Component string +} + +func (e AlreadyInstalledComponentError) Error() string { + return fmt.Sprintf("component %q is already installed", e.Component) +} + type NotInstalledError struct { Snap string Rev Revision diff --git a/store/store_action.go b/store/store_action.go index 20439021f38..fb75c386ce4 100644 --- a/store/store_action.go +++ b/store/store_action.go @@ -70,6 +70,9 @@ type CurrentSnap struct { // HeldBy is an optional array of snaps with holds on the current snap's // refreshes. The "system" snap represents a hold placed by the user. HeldBy []string + // Resources is a map of resource names to the resource revision that is + // currently installed for the snap. + Resources map[string]snap.Revision } type AssertionQuery interface { @@ -112,6 +115,11 @@ type SnapAction struct { CohortKey string Flags SnapActionFlags Epoch snap.Epoch + // ResourceInstall is a flag that indicates that this action is being used + // to fetch the list of resources that are available for a snap. This flag + // impacts how we decide to report an error if the snap has no updates + // available. + ResourceInstall bool // ValidationSets is an optional array of validation set primary keys // (relevant for install and refresh actions). ValidationSets []snapasserts.ValidationSetKey @@ -693,7 +701,18 @@ func (s *Store) snapAction(ctx context.Context, currentSnaps []*CurrentSnap, act return nil, nil, fmt.Errorf("unexpected invalid install/refresh API result: unexpected refresh") } rrev := snap.R(res.Snap.Revision) - if rrev == cur.Revision || findRev(rrev, cur.Block) { + + // here we check a few things to decide if the snap truly has no + // updates. + // * if the action is defined as a resource install, then the + // caller is just interested in the list of components so we + // do not return an error + // * then, we check if the snap exactly matches the snap that is + // currently installed. this considers the revision of the snap and + // its resources that are currently installed. if that isn't true, + // then we check if the snap's revision is in the list of blocked + // revisions. + if !refreshes[res.InstanceKey].ResourceInstall && (currentSnapMatchesStoreSnap(cur, res.Snap) || findRev(rrev, cur.Block)) { refreshErrors[cur.InstanceName] = ErrNoUpdateAvailable continue } @@ -757,6 +776,27 @@ func (s *Store) snapAction(ctx context.Context, currentSnaps []*CurrentSnap, act return sars, ars, nil } +func currentSnapMatchesStoreSnap(cur *CurrentSnap, storeSnap storeSnap) bool { + if cur.Revision.N != storeSnap.Revision { + return false + } + + for _, res := range storeSnap.Resources { + curRes, ok := cur.Resources[res.Name] + if !ok { + continue + } + + // TODO:COMPS: should local resources be considered when deciding if the + // snap has an update available? + if !curRes.Local() && curRes.N != res.Revision { + return false + } + } + + return true +} + func findRev(needle snap.Revision, haystack []snap.Revision) bool { for _, r := range haystack { if needle == r { diff --git a/store/store_action_test.go b/store/store_action_test.go index 54c1170dfae..00c884d76b2 100644 --- a/store/store_action_test.go +++ b/store/store_action_test.go @@ -547,6 +547,211 @@ func (s *storeActionSuite) TestSnapActionSkipBlocked(c *C) { }) } +func (s *storeActionSuite) TestSnapActionNoSkipIfResourceChange(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(c, r, "POST", snapActionPath) + // check device authorization is set, implicitly checking doRequest was used + c.Check(r.Header.Get("Snap-Device-Authorization"), Equals, `Macaroon root="device-macaroon"`) + + jsonReq, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + var req struct { + Context []map[string]interface{} `json:"context"` + Actions []map[string]interface{} `json:"actions"` + } + + err = json.Unmarshal(jsonReq, &req) + c.Assert(err, IsNil) + + c.Assert(req.Context, HasLen, 1) + c.Assert(req.Context[0], DeepEquals, map[string]interface{}{ + "snap-id": helloWorldSnapID, + "instance-key": helloWorldSnapID, + "revision": float64(1), + "tracking-channel": "stable", + "refreshed-date": helloRefreshedDateStr, + "epoch": iZeroEpoch, + }) + c.Assert(req.Actions, HasLen, 1) + c.Assert(req.Actions[0], DeepEquals, map[string]interface{}{ + "action": "refresh", + "instance-key": helloWorldSnapID, + "snap-id": helloWorldSnapID, + "channel": "stable", + }) + + io.WriteString(w, `{ + "results": [{ + "result": "refresh", + "instance-key": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "snap-id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "snap": { + "snap-id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "revision": 1, + "version": "6.1", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical" + }, + "resources": [ + { + "name": "comp", + "type": "component/test-component", + "revision": 3, + "version": "1", + "download": { + "sha3-384": "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b", + "size": 1024, + "url": "https://example.com/comp.comp" + } + } + ] + } + }] +}`) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + mockServerURL, _ := url.Parse(mockServer.URL) + cfg := store.Config{ + StoreBaseURL: mockServerURL, + } + dauthCtx := &testDauthContext{c: c, device: s.device} + sto := store.New(&cfg, dauthCtx) + + results, _, err := sto.SnapAction(s.ctx, []*store.CurrentSnap{ + { + InstanceName: "hello-world", + SnapID: helloWorldSnapID, + TrackingChannel: "stable", + Revision: snap.R(1), + RefreshedDate: helloRefreshedDate, + Resources: map[string]snap.Revision{ + "comp": snap.R(2), + }, + }, + }, []*store.SnapAction{ + { + Action: "refresh", + SnapID: helloWorldSnapID, + InstanceName: "hello-world", + Channel: "stable", + }, + }, nil, nil, &store.RefreshOptions{IncludeResources: true}) + c.Assert(err, IsNil) + c.Assert(results, HasLen, 1) + c.Assert(results[0].InstanceName(), Equals, "hello-world") +} + +func (s *storeActionSuite) TestSnapActionSkipIfNoResourceChange(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(c, r, "POST", snapActionPath) + // check device authorization is set, implicitly checking doRequest was used + c.Check(r.Header.Get("Snap-Device-Authorization"), Equals, `Macaroon root="device-macaroon"`) + + jsonReq, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + var req struct { + Context []map[string]interface{} `json:"context"` + Actions []map[string]interface{} `json:"actions"` + } + + err = json.Unmarshal(jsonReq, &req) + c.Assert(err, IsNil) + + c.Assert(req.Context, HasLen, 1) + c.Assert(req.Context[0], DeepEquals, map[string]interface{}{ + "snap-id": helloWorldSnapID, + "instance-key": helloWorldSnapID, + "revision": float64(1), + "tracking-channel": "stable", + "refreshed-date": helloRefreshedDateStr, + "epoch": iZeroEpoch, + }) + c.Assert(req.Actions, HasLen, 1) + c.Assert(req.Actions[0], DeepEquals, map[string]interface{}{ + "action": "refresh", + "instance-key": helloWorldSnapID, + "snap-id": helloWorldSnapID, + "channel": "stable", + }) + + io.WriteString(w, `{ + "results": [{ + "result": "refresh", + "instance-key": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "snap-id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "snap": { + "snap-id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "revision": 1, + "version": "6.1", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical" + }, + "resources": [ + { + "name": "comp", + "type": "component/test-component", + "revision": 2, + "version": "1", + "download": { + "sha3-384": "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b", + "size": 1024, + "url": "https://example.com/comp.comp" + } + } + ] + } + }] +}`) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + mockServerURL, _ := url.Parse(mockServer.URL) + cfg := store.Config{ + StoreBaseURL: mockServerURL, + } + dauthCtx := &testDauthContext{c: c, device: s.device} + sto := store.New(&cfg, dauthCtx) + + results, _, err := sto.SnapAction(s.ctx, []*store.CurrentSnap{ + { + InstanceName: "hello-world", + SnapID: helloWorldSnapID, + TrackingChannel: "stable", + Revision: snap.R(1), + RefreshedDate: helloRefreshedDate, + Resources: map[string]snap.Revision{ + "comp": snap.R(2), + }, + }, + }, []*store.SnapAction{ + { + Action: "refresh", + SnapID: helloWorldSnapID, + InstanceName: "hello-world", + Channel: "stable", + }, + }, nil, nil, &store.RefreshOptions{IncludeResources: true}) + c.Assert(results, HasLen, 0) + c.Check(err, DeepEquals, &store.SnapActionError{ + Refresh: map[string]error{ + "hello-world": store.ErrNoUpdateAvailable, + }, + }) +} + func (s *storeActionSuite) TestSnapActionSkipCurrent(c *C) { mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(c, r, "POST", snapActionPath) diff --git a/tests/main/component-from-store/task.yaml b/tests/main/component-from-store/task.yaml index 3aea817e065..84edaf3ceb9 100644 --- a/tests/main/component-from-store/task.yaml +++ b/tests/main/component-from-store/task.yaml @@ -19,5 +19,12 @@ execute: | # while this component is defined in the snap, it should not be installed not snap run test-snap-with-components three + # test installing a component for a snap that is already installed + snap install test-snap-with-components+three + + for comp in one two three; do + snap run test-snap-with-components ${comp} + done + # TODO:COMPS: test variations of installing snap with components at specific # revisions once PR to enable installing with revision and channel is merged