From 8b8a47eff0bc9077adb85a1ecda35d5148e50432 Mon Sep 17 00:00:00 2001 From: Andrew Phelps Date: Wed, 27 Nov 2024 12:39:17 -0500 Subject: [PATCH] o/snapstate, overlord, o/devicestate: support downloading components in snapstate.Download --- overlord/devicestate/devicestate.go | 5 +- .../devicestate/devicestate_systems_test.go | 20 +- overlord/devicestate/export_test.go | 2 +- overlord/managers_test.go | 6 +- overlord/snapstate/snapstate.go | 157 ++++- overlord/snapstate/snapstate_test.go | 575 +++++++++++++++++- overlord/snapstate/storehelpers.go | 72 +-- overlord/snapstate/target.go | 38 +- 8 files changed, 754 insertions(+), 121 deletions(-) diff --git a/overlord/devicestate/devicestate.go b/overlord/devicestate/devicestate.go index fa00b35dcc9..2fe4d56615e 100644 --- a/overlord/devicestate/devicestate.go +++ b/overlord/devicestate/devicestate.go @@ -1764,15 +1764,14 @@ func CreateRecoverySystem(st *state.State, label string, opts CreateRecoverySyst continue } - const userID = 0 // TODO: this respects the passed in validation sets, but does not // currently respect refresh-control style of constraining snap // revisions. - ts, info, err := snapstateDownload(context.TODO(), st, sn.Name, dirs.SnapBlobDir, &snapstate.RevisionOptions{ + ts, info, err := snapstateDownload(context.TODO(), st, sn.Name, nil, dirs.SnapBlobDir, snapstate.RevisionOptions{ Channel: sn.DefaultChannel, Revision: rev, ValidationSets: valsets, - }, userID, snapstate.Flags{}, nil) + }, snapstate.Options{}) if err != nil { return nil, err } diff --git a/overlord/devicestate/devicestate_systems_test.go b/overlord/devicestate/devicestate_systems_test.go index 64c704673e5..3cd6c23824c 100644 --- a/overlord/devicestate/devicestate_systems_test.go +++ b/overlord/devicestate/devicestate_systems_test.go @@ -3465,20 +3465,20 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid }, nil) devicestate.MockSnapstateDownload(func( - _ context.Context, _ *state.State, name string, _ string, opts *snapstate.RevisionOptions, _ int, _ snapstate.Flags, _ snapstate.DeviceContext) (*state.TaskSet, *snap.Info, error, + ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, *snap.Info, error, ) { expectedRev, ok := snapRevisions[name] if !ok { return nil, nil, fmt.Errorf("unexpected snap name %q", name) } - c.Check(expectedRev, Equals, opts.Revision) + c.Check(expectedRev, Equals, revOpts.Revision) - tDownload := s.state.NewTask("mock-download", fmt.Sprintf("Download %s to track %s", name, opts.Channel)) + tDownload := s.state.NewTask("mock-download", fmt.Sprintf("Download %s to track %s", name, revOpts.Channel)) si := &snap.SideInfo{ RealName: name, - Revision: opts.Revision, + Revision: revOpts.Revision, SnapID: fakeSnapID(name), } tDownload.Set("snap-setup", &snapstate.SnapSetup{ @@ -3654,7 +3654,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemOffli defer s.state.Unlock() devicestate.MockSnapstateDownload(func( - _ context.Context, _ *state.State, name string, _ string, opts *snapstate.RevisionOptions, _ int, _ snapstate.Flags, _ snapstate.DeviceContext) (*state.TaskSet, *snap.Info, error, + ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, *snap.Info, error, ) { c.Errorf("snapstate.Download called unexpectedly") return nil, nil, nil @@ -3825,7 +3825,7 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid assertstatetest.AddMany(s.state, vsetAssert) devicestate.MockSnapstateDownload(func( - _ context.Context, _ *state.State, name string, _ string, opts *snapstate.RevisionOptions, _ int, _ snapstate.Flags, _ snapstate.DeviceContext) (*state.TaskSet, *snap.Info, error, + ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, *snap.Info, error, ) { c.Errorf("snapstate.Download called unexpectedly") return nil, nil, nil @@ -4310,19 +4310,19 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid vset := vsetAssert.(*asserts.ValidationSet) devicestate.MockSnapstateDownload(func( - _ context.Context, _ *state.State, name string, _ string, opts *snapstate.RevisionOptions, _ int, _ snapstate.Flags, _ snapstate.DeviceContext) (*state.TaskSet, *snap.Info, error, + ctx context.Context, st *state.State, name string, components []string, blobDirectory string, revOpts snapstate.RevisionOptions, opts snapstate.Options) (*state.TaskSet, *snap.Info, error, ) { expectedRev, ok := snapRevisions[name] if !ok { return nil, nil, fmt.Errorf("unexpected snap name %q", name) } - c.Check(expectedRev, Equals, opts.Revision) + c.Check(expectedRev, Equals, revOpts.Revision) - tDownload := s.state.NewTask("fake-download", fmt.Sprintf("Download %s to track %s", name, opts.Channel)) + tDownload := s.state.NewTask("fake-download", fmt.Sprintf("Download %s to track %s", name, revOpts.Channel)) si := &snap.SideInfo{ RealName: name, - Revision: opts.Revision, + Revision: revOpts.Revision, SnapID: fakeSnapID(name), } diff --git a/overlord/devicestate/export_test.go b/overlord/devicestate/export_test.go index 8e5ae5a03b9..d2d287e8b34 100644 --- a/overlord/devicestate/export_test.go +++ b/overlord/devicestate/export_test.go @@ -185,7 +185,7 @@ func MockSnapstateUpdatePathWithDeviceContext(f func(st *state.State, si *snap.S return r } -func MockSnapstateDownload(f func(ctx context.Context, st *state.State, name string, blobDirectory string, opts *snapstate.RevisionOptions, userID int, flags snapstate.Flags, deviceCtx snapstate.DeviceContext) (*state.TaskSet, *snap.Info, error)) (restore func()) { +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 return r diff --git a/overlord/managers_test.go b/overlord/managers_test.go index 6a5bd9b611a..e6667233d29 100644 --- a/overlord/managers_test.go +++ b/overlord/managers_test.go @@ -13693,7 +13693,7 @@ func (s *mgrsSuite) testDownload(c *C, downloadDir string) { st.Lock() defer st.Unlock() - ts, info, err := snapstate.Download(context.TODO(), st, "foo", downloadDir, nil, 0, snapstate.Flags{}, nil) + ts, info, err := snapstate.Download(context.TODO(), st, "foo", nil, downloadDir, snapstate.RevisionOptions{}, snapstate.Options{}) c.Assert(err, IsNil) chg := st.NewChange("download-snap", "...") chg.AddAll(ts) @@ -13753,9 +13753,9 @@ func (s *mgrsSuite) TestDownloadSpecificRevision(c *C) { st.Lock() defer st.Unlock() - ts, info, err := snapstate.Download(context.TODO(), st, "foo", "", &snapstate.RevisionOptions{ + ts, info, err := snapstate.Download(context.TODO(), st, "foo", nil, "", snapstate.RevisionOptions{ Revision: snap.R(snapOldRev), - }, 0, snapstate.Flags{}, nil) + }, snapstate.Options{}) c.Assert(err, IsNil) chg := st.NewChange("download-snap", "...") chg.AddAll(ts) diff --git a/overlord/snapstate/snapstate.go b/overlord/snapstate/snapstate.go index 1ec4bccb3b7..732c8d8e818 100644 --- a/overlord/snapstate/snapstate.go +++ b/overlord/snapstate/snapstate.go @@ -1535,22 +1535,30 @@ func InstallPathWithDeviceContext(st *state.State, si *snap.SideInfo, path, name return ts, nil } -// Download returns a set of tasks for downloading a snap into the given -// blobDirectory. If blobDirectory is empty, then dirs.SnapBlobDir is used. The -// snap.Info for the snap that is downloaded is also returned. The tasks that -// are returned will also download and validate the snap's assertion. -// Prerequisites for the snap are not downloaded. -func Download(ctx context.Context, st *state.State, name string, blobDirectory string, opts *RevisionOptions, userID int, flags Flags, deviceCtx DeviceContext) (*state.TaskSet, *snap.Info, error) { - if opts == nil { - opts = &RevisionOptions{} +// Download returns a set of tasks for downloading a snap and components into +// the given blobDirectory. If blobDirectory is empty, then dirs.SnapBlobDir is +// used. The snap.Info for the snap that is downloaded is also returned. The +// tasks that are returned will also download and validate the snap's and +// components' assertions. Prerequisites for the snap are not downloaded. +func Download( + ctx context.Context, + st *state.State, + name string, + components []string, + blobDirectory string, + revOpts RevisionOptions, + opts Options, +) (*state.TaskSet, *snap.Info, error) { + if revOpts.CohortKey != "" && !revOpts.Revision.Unset() { + return nil, nil, errors.New("internal error: cannot specify revision and cohort") } - if opts.CohortKey != "" && !opts.Revision.Unset() { - return nil, nil, errors.New("cannot specify revision and cohort") + if revOpts.Channel == "" { + revOpts.Channel = "stable" } - if opts.Channel == "" { - opts.Channel = "stable" + if revOpts.ValidationSets == nil { + revOpts.ValidationSets = snapasserts.NewValidationSets() } var snapst SnapState @@ -1563,35 +1571,40 @@ func Download(ctx context.Context, st *state.State, name string, blobDirectory s return nil, nil, fmt.Errorf("invalid instance name: %v", err) } - sar, err := downloadInfo(ctx, st, name, opts, userID, deviceCtx) + sars, err := sendDownloadActions(ctx, st, []StoreSnap{{ + InstanceName: name, + Components: components, + RevOpts: revOpts, + }}, opts) if err != nil { return nil, nil, err } + if len(sars) != 1 { + return nil, nil, fmt.Errorf("expected exactly one result from the store, got %d", len(sars)) + } + sar := sars[0] info := sar.Info - // if we are going to use the default download dir, and the same snap - // revision is already installed, then we should not overwrite the snap that - // is already in the dir. - if (blobDirectory == "" || blobDirectory == dirs.SnapBlobDir) && info.Revision == snapst.Current { - return nil, nil, &snap.AlreadyInstalledError{Snap: name} + if opts.PrereqTracker != nil { + opts.PrereqTracker.Add(info) } - if flags.RequireTypeBase && info.Type() != snap.TypeBase && info.Type() != snap.TypeOS { + if opts.Flags.RequireTypeBase && info.Type() != snap.TypeBase && info.Type() != snap.TypeOS { return nil, nil, fmt.Errorf("unexpected snap type %q, instead of 'base'", info.Type()) } snapsup := &SnapSetup{ - Channel: opts.Channel, + Channel: revOpts.Channel, Base: info.Base, - UserID: userID, - Flags: flags.ForSnapSetup(), + UserID: opts.UserID, + Flags: opts.Flags.ForSnapSetup(), DownloadInfo: &info.DownloadInfo, SideInfo: &info.SideInfo, Type: info.Type(), Version: info.Version, InstanceKey: info.InstanceKey, - CohortKey: opts.CohortKey, + CohortKey: revOpts.CohortKey, ExpectedProvenance: info.SnapProvenance, DownloadBlobDir: blobDirectory, } @@ -1600,25 +1613,105 @@ func Download(ctx context.Context, st *state.State, name string, blobDirectory s snapsup.Channel = sar.RedirectChannel } + compsups, err := componentTargetsFromActionResult("download", sar, components) + if err != nil { + return nil, nil, fmt.Errorf("cannot extract components from snap resources: %w", err) + } + + for i := range compsups { + compsups[i].DownloadBlobDir = blobDirectory + } + + if err := checkSnapActionAgainstValidationSets(sar, compsups, "download", revOpts.ValidationSets); err != nil { + return nil, nil, err + } + toDownloadTo := filepath.Dir(snapsup.MountFile()) + + // TODO:COMPS: support checking for available space for components if err := checkDiskSpaceDownload([]minimalInstallInfo{installSnapInfo{info}}, toDownloadTo); err != nil { return nil, nil, err } + snapAlreadyInstalled := (blobDirectory == "" || blobDirectory == dirs.SnapBlobDir) && + snapst.Sequence.LastIndex(info.Revision) != -1 + componentAlreadyInstalled := make(map[string]bool, len(compsups)) + allInstalled := snapAlreadyInstalled + for _, c := range compsups { + componentAlreadyInstalled[c.ComponentName()] = (blobDirectory == "" || blobDirectory == dirs.SnapBlobDir) && + snapst.Sequence.IsComponentRevPresent(c.CompSideInfo) + allInstalled = allInstalled && componentAlreadyInstalled[c.ComponentName()] + } + + if allInstalled { + return nil, nil, &snap.AlreadyInstalledError{Snap: name} + } + + ts := state.NewTaskSet() + var snapsupTask, prev *state.Task + addTask := func(t *state.Task) { + ts.AddTask(t) + if prev == nil { + t.Set("snap-setup", snapsup) + snapsupTask = t + ts.MarkEdge(t, BeginEdge) + } else { + t.WaitFor(prev) + t.Set("snap-setup-task", snapsupTask.ID()) + } + prev = t + } + revisionStr := fmt.Sprintf(" (%s)", snapsup.Revision()) - download := st.NewTask("download-snap", fmt.Sprintf(i18n.G("Download snap %q%s from channel %q"), snapsup.InstanceName(), revisionStr, snapsup.Channel)) - download.Set("snap-setup", snapsup) + if !snapAlreadyInstalled { + download := st.NewTask("download-snap", fmt.Sprintf(i18n.G("Download snap %q%s from channel %q"), snapsup.InstanceName(), revisionStr, snapsup.Channel)) + addTask(download) + } else { + // validate-snap expects this to be set, and since we know that this + // already should exist, we can set it here + snapsup.SnapPath = snapsup.MountFile() + } + + validate := st.NewTask("validate-snap", fmt.Sprintf(i18n.G("Fetch and check assertions for snap %q%s"), snapsup.InstanceName(), revisionStr)) + addTask(validate) - checkAsserts := st.NewTask("validate-snap", fmt.Sprintf(i18n.G("Fetch and check assertions for snap %q%s"), snapsup.InstanceName(), revisionStr)) - checkAsserts.Set("snap-setup-task", download.ID()) - checkAsserts.WaitFor(download) + compsupIDs := make([]string, 0, len(compsups)) + for _, c := range compsups { + rev := fmt.Sprintf(" (%s)", c.CompSideInfo.Revision) - installSet := state.NewTaskSet(download, checkAsserts) - installSet.MarkEdge(download, BeginEdge) - installSet.MarkEdge(checkAsserts, LastBeforeLocalModificationsEdge) + var compsupTaskID string + if !componentAlreadyInstalled[c.ComponentName()] { + download := st.NewTask("download-component", fmt.Sprintf(i18n.G("Download component %q%s"), c.ComponentName(), rev)) + download.Set("component-setup", c) + addTask(download) + compsupTaskID = download.ID() + } + + // even if the component itself is already installed, it might not have + // been installed with the same snap revision. in that case, + // validate-component will fetch new assertions from the store. + validate := st.NewTask("validate-component", fmt.Sprintf( + i18n.G("Fetch and check assertions for component %q%s"), c.ComponentName(), rev), + ) + if compsupTaskID == "" { + validate.Set("component-setup", c) + compsupTaskID = validate.ID() + } else { + validate.Set("component-setup-task", compsupTaskID) + } + addTask(validate) - return installSet, info, nil + compsupIDs = append(compsupIDs, compsupTaskID) + } + + snapsupTask.Set("component-setup-tasks", compsupIDs) + + // since nothing in this function does any "local" modifications, we just + // set this edge on the last task in the chain + ts.MarkEdge(prev, LastBeforeLocalModificationsEdge) + + return ts, info, nil } func validatedInfoFromPathAndSideInfo(instanceName string, path string, si *snap.SideInfo) (*snap.Info, error) { diff --git a/overlord/snapstate/snapstate_test.go b/overlord/snapstate/snapstate_test.go index 68a94355398..3c1af41db21 100644 --- a/overlord/snapstate/snapstate_test.go +++ b/overlord/snapstate/snapstate_test.go @@ -9714,7 +9714,10 @@ func (s *snapmgrTestSuite) TestDownload(c *C) { s.state.Lock() defer s.state.Unlock() - ts, info, err := snapstate.Download(context.Background(), s.state, "foo", "", nil, 0, snapstate.Flags{}, nil) + prqt := testPrereqTracker{} + ts, info, err := snapstate.Download(context.Background(), s.state, "foo", nil, "", snapstate.RevisionOptions{}, snapstate.Options{ + PrereqTracker: &prqt, + }) c.Assert(err, IsNil) c.Check(info.SideInfo, DeepEquals, snap.SideInfo{ @@ -9742,15 +9745,565 @@ func (s *snapmgrTestSuite) TestDownload(c *C) { err = validateSnap.Get("snap-setup-task", &snapsupTaskID) c.Assert(err, IsNil) c.Check(snapsupTaskID, Equals, downloadSnap.ID()) + + c.Check(prqt.infos, DeepEquals, []*snap.Info{info}) +} + +func (s *snapmgrTestSuite) TestDownloadWithComponents(c *C) { + s.testDownloadWithComponents(c, "") +} + +func (s *snapmgrTestSuite) TestDownloadWithComponentsSpecificDownloadDir(c *C) { + s.testDownloadWithComponents(c, c.MkDir()) +} + +func (s *snapmgrTestSuite) testDownloadWithComponents(c *C, downloadDir string) { + s.state.Lock() + defer s.state.Unlock() + + components := map[string]snap.Revision{ + "comp-1": snap.R(1), + "comp-2": snap.R(2), + } + + s.fakeStore.registerID("snap-1", snaptest.AssertedSnapID("snap-1")) + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + results := make([]store.SnapResourceResult, 0, len(components)) + for comp, rev := range components { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + comp, + }, + Name: comp, + Revision: rev.N, + Type: "component/standard", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }) + } + return results + } + + s.fakeStore.mutateSnapInfo = func(info *snap.Info) error { + info.Components = map[string]*snap.Component{ + "comp-1": { + Type: snap.StandardComponent, + Name: "comp-1", + }, + "comp-2": { + Type: snap.StandardComponent, + Name: "comp-2", + }, + } + return nil + } + + ts, info, err := snapstate.Download( + context.Background(), + s.state, + "snap-1", + []string{"comp-1", "comp-2"}, + downloadDir, + snapstate.RevisionOptions{}, + snapstate.Options{}, + ) + c.Assert(err, IsNil) + + c.Check(info.SideInfo, DeepEquals, snap.SideInfo{ + RealName: "snap-1", + Revision: snap.R(11), + SnapID: snaptest.AssertedSnapID("snap-1"), + Channel: "stable", + }) + + kinds := make([]string, 0, len(ts.Tasks())) + for _, t := range ts.Tasks() { + kinds = append(kinds, t.Kind()) + } + c.Assert(kinds, DeepEquals, []string{ + "download-snap", + "validate-snap", + "download-component", + "validate-component", + "download-component", + "validate-component", + }) + + last := ts.MaybeEdge(snapstate.LastBeforeLocalModificationsEdge) + c.Assert(last, NotNil) + c.Check(last.Kind(), Equals, "validate-component") + + begin := ts.MaybeEdge(snapstate.BeginEdge) + c.Assert(begin, NotNil) + c.Check(begin.Kind(), Equals, "download-snap") + + verifySnapAndComponentSetupsForDownload(c, begin, ts, downloadDir) +} + +func (s *snapmgrTestSuite) TestDownloadWithComponentsWithMismatchValidationSets(c *C) { + s.state.Lock() + defer s.state.Unlock() + + components := map[string]snap.Revision{ + "comp-1": snap.R(1), + "comp-2": snap.R(2), + } + + s.fakeStore.registerID("snap-1", snaptest.AssertedSnapID("snap-1")) + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + results := make([]store.SnapResourceResult, 0, len(components)) + for comp, rev := range components { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + comp, + }, + Name: comp, + Revision: rev.N + 1, + Type: "component/standard", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }) + } + return results + } + + s.fakeStore.mutateSnapInfo = func(info *snap.Info) error { + info.Components = map[string]*snap.Component{ + "comp-1": { + Type: snap.StandardComponent, + Name: "comp-1", + }, + "comp-2": { + Type: snap.StandardComponent, + Name: "comp-2", + }, + } + return nil + } + + headers := map[string]interface{}{ + "type": "validation-set", + "timestamp": time.Now().Format(time.RFC3339), + "authority-id": "foo", + "series": "16", + "account-id": "foo", + "name": "bar", + "sequence": "3", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-1", + "id": snaptest.AssertedSnapID("snap-1"), + "presence": "required", + "revision": "11", + "components": map[string]interface{}{ + "comp-1": map[string]interface{}{ + "presence": "required", + "revision": "1", + }, + }, + }, + }, + } + + signing := assertstest.NewStoreStack("can0nical", nil) + a, err := signing.Sign(asserts.ValidationSetType, headers, nil, "") + c.Assert(err, IsNil) + vs := a.(*asserts.ValidationSet) + + vsets := snapasserts.NewValidationSets() + err = vsets.Add(vs) + c.Assert(err, IsNil) + c.Assert(vsets.Conflict(), IsNil) + + _, _, err = snapstate.Download( + context.Background(), + s.state, + "snap-1", + []string{"comp-1", "comp-2"}, + "", + snapstate.RevisionOptions{ + ValidationSets: vsets, + }, + snapstate.Options{}, + ) + c.Assert(err, ErrorMatches, `cannot download component "snap-1\+comp-1" at revision 2 without --ignore-validation, revision 1 is required by validation sets: 16/foo/bar/3`) +} + +func (s *snapmgrTestSuite) TestDownloadWithComponentsWithValidationSets(c *C) { + s.state.Lock() + defer s.state.Unlock() + + components := map[string]snap.Revision{ + "comp-1": snap.R(1), + "comp-2": snap.R(2), + } + + s.fakeStore.registerID("snap-1", snaptest.AssertedSnapID("snap-1")) + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + results := make([]store.SnapResourceResult, 0, len(components)) + for comp, rev := range components { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + comp, + }, + Name: comp, + Revision: rev.N, + Type: "component/standard", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }) + } + return results + } + + s.fakeStore.mutateSnapInfo = func(info *snap.Info) error { + info.Components = map[string]*snap.Component{ + "comp-1": { + Type: snap.StandardComponent, + Name: "comp-1", + }, + "comp-2": { + Type: snap.StandardComponent, + Name: "comp-2", + }, + } + return nil + } + + headers := map[string]interface{}{ + "type": "validation-set", + "timestamp": time.Now().Format(time.RFC3339), + "authority-id": "foo", + "series": "16", + "account-id": "foo", + "name": "bar", + "sequence": "3", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-1", + "id": snaptest.AssertedSnapID("snap-1"), + "presence": "required", + "revision": "11", + "components": map[string]interface{}{ + "comp-1": map[string]interface{}{ + "presence": "required", + "revision": "1", + }, + "comp-2": map[string]interface{}{ + "presence": "required", + "revision": "2", + }, + }, + }, + }, + } + + signing := assertstest.NewStoreStack("can0nical", nil) + a, err := signing.Sign(asserts.ValidationSetType, headers, nil, "") + c.Assert(err, IsNil) + vs := a.(*asserts.ValidationSet) + + vsets := snapasserts.NewValidationSets() + err = vsets.Add(vs) + c.Assert(err, IsNil) + c.Assert(vsets.Conflict(), IsNil) + + ts, info, err := snapstate.Download( + context.Background(), + s.state, + "snap-1", + []string{"comp-1", "comp-2"}, + "", + snapstate.RevisionOptions{ + ValidationSets: vsets, + }, + snapstate.Options{}, + ) + c.Assert(err, IsNil) + + c.Check(info.SideInfo, DeepEquals, snap.SideInfo{ + RealName: "snap-1", + Revision: snap.R(11), + SnapID: snaptest.AssertedSnapID("snap-1"), + }) + + kinds := make([]string, 0, len(ts.Tasks())) + for _, t := range ts.Tasks() { + kinds = append(kinds, t.Kind()) + } + c.Assert(kinds, DeepEquals, []string{ + "download-snap", + "validate-snap", + "download-component", + "validate-component", + "download-component", + "validate-component", + }) + + last := ts.MaybeEdge(snapstate.LastBeforeLocalModificationsEdge) + c.Assert(last, NotNil) + c.Check(last.Kind(), Equals, "validate-component") + + begin := ts.MaybeEdge(snapstate.BeginEdge) + c.Assert(begin, NotNil) + c.Check(begin.Kind(), Equals, "download-snap") + + verifySnapAndComponentSetupsForDownload(c, begin, ts, "") +} + +func (s *snapmgrTestSuite) TestDownloadWithComponentsAlreadyInstalledSnap(c *C) { + s.state.Lock() + defer s.state.Unlock() + + components := map[string]snap.Revision{ + "comp-1": snap.R(1), + "comp-2": snap.R(2), + } + + s.fakeStore.registerID("snap-1", snaptest.AssertedSnapID("snap-1")) + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + results := make([]store.SnapResourceResult, 0, len(components)) + for comp, rev := range components { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + comp, + }, + Name: comp, + Revision: rev.N, + Type: "component/standard", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }) + } + return results + } + + s.fakeStore.mutateSnapInfo = func(info *snap.Info) error { + info.Components = map[string]*snap.Component{ + "comp-1": { + Type: snap.StandardComponent, + Name: "comp-1", + }, + "comp-2": { + Type: snap.StandardComponent, + Name: "comp-2", + }, + } + return nil + } + + snapstate.Set(s.state, "snap-1", &snapstate.SnapState{ + Current: snap.R(11), + Sequence: snapstatetest.NewSequenceFromSnapSideInfos([]*snap.SideInfo{ + {RealName: "snap-1", SnapID: snaptest.AssertedSnapID("snap-1"), Revision: snap.R(11)}, + }), + Active: true, + SnapType: "app", + }) + + ts, info, err := snapstate.Download( + context.Background(), + s.state, + "snap-1", + []string{"comp-1", "comp-2"}, + "", + snapstate.RevisionOptions{}, + snapstate.Options{}, + ) + c.Assert(err, IsNil) + + c.Check(info.SideInfo, DeepEquals, snap.SideInfo{ + RealName: "snap-1", + Revision: snap.R(11), + SnapID: snaptest.AssertedSnapID("snap-1"), + Channel: "stable", + }) + + kinds := make([]string, 0, len(ts.Tasks())) + for _, t := range ts.Tasks() { + kinds = append(kinds, t.Kind()) + } + c.Assert(kinds, DeepEquals, []string{ + "validate-snap", + "download-component", + "validate-component", + "download-component", + "validate-component", + }) + + last := ts.MaybeEdge(snapstate.LastBeforeLocalModificationsEdge) + c.Assert(last, NotNil) + c.Check(last.Kind(), Equals, "validate-component") + + begin := ts.MaybeEdge(snapstate.BeginEdge) + c.Assert(begin, NotNil) + c.Check(begin.Kind(), Equals, "validate-snap") + + verifySnapAndComponentSetupsForDownload(c, begin, ts, "") +} + +func (s *snapmgrTestSuite) TestDownloadWithComponentsAlreadyInstalledSnapAndComponent(c *C) { + s.state.Lock() + defer s.state.Unlock() + + components := map[string]snap.Revision{ + "comp-1": snap.R(1), + "comp-2": snap.R(2), + } + + s.fakeStore.registerID("snap-1", snaptest.AssertedSnapID("snap-1")) + s.fakeStore.snapResourcesFn = func(info *snap.Info) []store.SnapResourceResult { + results := make([]store.SnapResourceResult, 0, len(components)) + for comp, rev := range components { + results = append(results, store.SnapResourceResult{ + DownloadInfo: snap.DownloadInfo{ + DownloadURL: "http://example.com/" + comp, + }, + Name: comp, + Revision: rev.N, + Type: "component/standard", + Version: "1.0", + CreatedAt: "2024-01-01T00:00:00Z", + }) + } + return results + } + + s.fakeStore.mutateSnapInfo = func(info *snap.Info) error { + info.Components = map[string]*snap.Component{ + "comp-1": { + Type: snap.StandardComponent, + Name: "comp-1", + }, + "comp-2": { + Type: snap.StandardComponent, + Name: "comp-2", + }, + } + return nil + } + + s.AddCleanup(snapstate.MockReadComponentInfo(func( + compMntDir string, snapInfo *snap.Info, csi *snap.ComponentSideInfo) (*snap.ComponentInfo, error) { + return &snap.ComponentInfo{ + Component: csi.Component, + Type: snap.StandardComponent, + CompVersion: "1.0", + ComponentSideInfo: *csi, + }, nil + })) + + seq := snapstatetest.NewSequenceFromSnapSideInfos([]*snap.SideInfo{ + {RealName: "snap-1", SnapID: snaptest.AssertedSnapID("snap-1"), Revision: snap.R(11)}, + }) + + seq.AddComponentForRevision(snap.R(11), sequence.NewComponentState(&snap.ComponentSideInfo{ + Component: naming.NewComponentRef("snap-1", "comp-1"), + Revision: components["comp-1"], + }, snap.StandardComponent)) + + snapstate.Set(s.state, "snap-1", &snapstate.SnapState{ + Current: snap.R(11), + Sequence: seq, + Active: true, + SnapType: "app", + }) + + ts, info, err := snapstate.Download( + context.Background(), + s.state, + "snap-1", + []string{"comp-1", "comp-2"}, + "", + snapstate.RevisionOptions{}, + snapstate.Options{}, + ) + c.Assert(err, IsNil) + + c.Check(info.SideInfo, DeepEquals, snap.SideInfo{ + RealName: "snap-1", + Revision: snap.R(11), + SnapID: snaptest.AssertedSnapID("snap-1"), + Channel: "stable", + }) + + kinds := make([]string, 0, len(ts.Tasks())) + for _, t := range ts.Tasks() { + kinds = append(kinds, t.Kind()) + } + c.Assert(kinds, DeepEquals, []string{ + "validate-snap", + "validate-component", + "download-component", + "validate-component", + }) + + last := ts.MaybeEdge(snapstate.LastBeforeLocalModificationsEdge) + c.Assert(last, NotNil) + c.Check(last.Kind(), Equals, "validate-component") + + begin := ts.MaybeEdge(snapstate.BeginEdge) + c.Assert(begin, NotNil) + c.Check(begin.Kind(), Equals, "validate-snap") + + verifySnapAndComponentSetupsForDownload(c, begin, ts, "") +} + +func verifySnapAndComponentSetupsForDownload(c *C, begin *state.Task, ts *state.TaskSet, downloadDir string) { + var snapsup snapstate.SnapSetup + err := begin.Get("snap-setup", &snapsup) + c.Assert(err, IsNil) + c.Check(snapsup.DownloadBlobDir, Equals, downloadDir) + + expectedDownloadDir := downloadDir + if expectedDownloadDir == "" { + expectedDownloadDir = dirs.SnapBlobDir + } + + c.Check(snapsup.MountFile(), Equals, filepath.Join( + expectedDownloadDir, + fmt.Sprintf("%s_%s.snap", snapsup.InstanceName(), snapsup.Revision()), + )) + + var compsupTaskIDs []string + err = begin.Get("component-setup-tasks", &compsupTaskIDs) + c.Assert(err, IsNil) + c.Assert(compsupTaskIDs, HasLen, 2) + + for _, t := range ts.Tasks()[1:] { + var snapsupTaskID string + err := t.Get("snap-setup-task", &snapsupTaskID) + c.Assert(err, IsNil) + c.Check(snapsupTaskID, Equals, begin.ID()) + } + + for _, t := range ts.Tasks() { + if t.Has("component-setup") { + var compsup snapstate.ComponentSetup + err := t.Get("component-setup", &compsup) + c.Assert(err, IsNil) + c.Check(compsup.DownloadBlobDir, Equals, downloadDir) + + c.Check(compsup.MountFile(compsup.CompSideInfo.Component.SnapName), Equals, filepath.Join( + expectedDownloadDir, + fmt.Sprintf("%s+%s_%s.comp", snapsup.InstanceName(), compsup.ComponentName(), compsup.Revision()), + )) + + c.Assert(t.ID(), Equals, compsupTaskIDs[0]) + compsupTaskIDs = compsupTaskIDs[1:] + } + } + + c.Assert(compsupTaskIDs, HasLen, 0) } func (s *snapmgrTestSuite) TestDownloadSpecifyRevision(c *C) { s.state.Lock() defer s.state.Unlock() - ts, info, err := snapstate.Download(context.Background(), s.state, "foo", "", &snapstate.RevisionOptions{ + ts, info, err := snapstate.Download(context.Background(), s.state, "foo", nil, "", snapstate.RevisionOptions{ Revision: snap.R(2), - }, 0, snapstate.Flags{}, nil) + }, snapstate.Options{}) c.Assert(err, IsNil) c.Check(ts.Tasks(), HasLen, 2) @@ -9759,6 +10312,7 @@ func (s *snapmgrTestSuite) TestDownloadSpecifyRevision(c *C) { RealName: "foo", Revision: snap.R(2), SnapID: "foo-id", + Channel: "stable", }) downloadSnap := ts.MaybeEdge(snapstate.BeginEdge) @@ -9786,15 +10340,16 @@ func (s *snapmgrTestSuite) TestDownloadSpecifyDownloadDir(c *C) { downloadDir := c.MkDir() - ts, info, err := snapstate.Download(context.Background(), s.state, "foo", downloadDir, &snapstate.RevisionOptions{ + ts, info, err := snapstate.Download(context.Background(), s.state, "foo", nil, downloadDir, snapstate.RevisionOptions{ Revision: snap.R(1), - }, 0, snapstate.Flags{}, nil) + }, snapstate.Options{}) c.Assert(err, IsNil) c.Check(info.SideInfo, DeepEquals, snap.SideInfo{ RealName: "foo", Revision: snap.R(1), SnapID: "foo-id", + Channel: "stable", }) c.Check(ts.Tasks(), HasLen, 2) @@ -9827,9 +10382,9 @@ func (s *snapmgrTestSuite) TestDownloadOutOfSpace(c *C) { s.state.Lock() defer s.state.Unlock() - _, _, err := snapstate.Download(context.Background(), s.state, "foo", "", &snapstate.RevisionOptions{ + _, _, err := snapstate.Download(context.Background(), s.state, "foo", nil, "", snapstate.RevisionOptions{ Revision: snap.R(2), - }, 0, snapstate.Flags{}, nil) + }, snapstate.Options{}) c.Assert(err, NotNil) diskSpaceErr, ok := err.(*snapstate.InsufficientSpaceError) @@ -9853,7 +10408,7 @@ func (s *snapmgrTestSuite) TestDownloadAlreadyInstalled(c *C) { }) const downloadDir = "" - _, _, err := snapstate.Download(context.Background(), s.state, "foo", downloadDir, nil, 0, snapstate.Flags{}, nil) + _, _, err := snapstate.Download(context.Background(), s.state, "foo", nil, downloadDir, snapstate.RevisionOptions{}, snapstate.Options{}) c.Assert(err, NotNil) alreadyInstalledErr, ok := err.(*snap.AlreadyInstalledError) @@ -9865,8 +10420,8 @@ func (s *snapmgrTestSuite) TestDownloadSpecifyCohort(c *C) { s.state.Lock() defer s.state.Unlock() - opts := &snapstate.RevisionOptions{Channel: "some-channel", CohortKey: "cohort-key"} - ts, info, err := snapstate.Download(context.Background(), s.state, "foo", "", opts, 0, snapstate.Flags{}, nil) + opts := snapstate.RevisionOptions{Channel: "some-channel", CohortKey: "cohort-key"} + ts, info, err := snapstate.Download(context.Background(), s.state, "foo", nil, "", opts, snapstate.Options{}) c.Assert(err, IsNil) c.Check(ts.Tasks(), HasLen, 2) diff --git a/overlord/snapstate/storehelpers.go b/overlord/snapstate/storehelpers.go index 005f00478a1..8fb05d45927 100644 --- a/overlord/snapstate/storehelpers.go +++ b/overlord/snapstate/storehelpers.go @@ -223,45 +223,6 @@ var installSize = func(st *state.State, snaps []minimalInstallInfo, userID int, return total, nil } -func downloadInfo(ctx context.Context, st *state.State, name string, revOpts *RevisionOptions, userID int, deviceCtx DeviceContext) (store.SnapActionResult, error) { - curSnaps, err := currentSnaps(st) - if err != nil { - return store.SnapActionResult{}, err - } - - user, err := userFromUserID(st, userID) - if err != nil { - return store.SnapActionResult{}, err - } - - opts, err := refreshOptions(st, nil) - if err != nil { - return store.SnapActionResult{}, err - } - - action := &store.SnapAction{ - Action: "download", - InstanceName: name, - } - - if revOpts != nil { - // cannot specify both with the API - if revOpts.Revision.Unset() { - action.Channel = revOpts.Channel - action.CohortKey = revOpts.CohortKey - } else { - action.Revision = revOpts.Revision - } - } - - theStore := Store(st, deviceCtx) - st.Unlock() // calls to the store should be done without holding the state lock - res, _, err := theStore.SnapAction(ctx, curSnaps, []*store.SnapAction{action}, nil, user, opts) - st.Lock() - - return singleActionResult(name, action.Action, res, err) -} - var ErrMissingExpectedResult = fmt.Errorf("unexpectedly empty response from the server (try again later)") func singleActionResultErr(name, action string, e error) error { @@ -296,18 +257,6 @@ func singleActionResultErr(name, action string, e error) error { return e } -func singleActionResult(name, action string, results []store.SnapActionResult, e error) (store.SnapActionResult, error) { - if len(results) > 1 { - return store.SnapActionResult{}, fmt.Errorf("internal error: multiple store results for a single snap op") - } - if len(results) > 0 { - // TODO: if we also have an error log/warn about it - return results[0], nil - } - - return store.SnapActionResult{}, singleActionResultErr(name, action, e) -} - func currentSnapsImpl(st *state.State) ([]*store.CurrentSnap, error) { snapStates, err := All(st) if err != nil { @@ -1025,17 +974,24 @@ func sendOneInstallAction(ctx context.Context, st *state.State, snaps StoreSnap, return results[0], nil } -func sendInstallActions( - ctx context.Context, - st *state.State, - snaps []StoreSnap, - opts Options, -) ([]store.SnapActionResult, error) { +func sendInstallActions(ctx context.Context, st *state.State, snaps []StoreSnap, opts Options) ([]store.SnapActionResult, error) { + return sendInstallOrDownloadActions(ctx, st, "install", snaps, opts) +} + +func sendDownloadActions(ctx context.Context, st *state.State, snaps []StoreSnap, opts Options) ([]store.SnapActionResult, error) { + return sendInstallOrDownloadActions(ctx, st, "download", snaps, opts) +} + +func sendInstallOrDownloadActions(ctx context.Context, st *state.State, action string, snaps []StoreSnap, opts Options) ([]store.SnapActionResult, error) { + if action != "install" && action != "download" { + return nil, fmt.Errorf("internal error: action must be install or download: %s", action) + } + includeResources := false actions := make([]*store.SnapAction, 0, len(snaps)) for _, sn := range snaps { action := &store.SnapAction{ - Action: "install", + Action: action, InstanceName: sn.InstanceName, } diff --git a/overlord/snapstate/target.go b/overlord/snapstate/target.go index 521156159b6..2c97312c8c7 100644 --- a/overlord/snapstate/target.go +++ b/overlord/snapstate/target.go @@ -344,6 +344,24 @@ func checkTargetAgainstValidationSets(target target, action string, vsets *snapa return checkComponentsAgainstConstraints(target.info.SnapName(), comps, constraints, action) } +func checkSnapActionAgainstValidationSets(sar store.SnapActionResult, components []ComponentSetup, action string, vsets *snapasserts.ValidationSets) error { + constraints, err := vsets.Presence(sar.Info) + if err != nil { + return err + } + + if err := checkSnapAgainstConstraints(sar.InstanceName(), sar.Revision, constraints, action); err != nil { + return err + } + + comps := make(map[string]snap.Revision, len(components)) + for _, comp := range components { + comps[comp.ComponentName()] = comp.Revision() + } + + return checkComponentsAgainstConstraints(sar.SnapName(), comps, constraints, action) +} + func checkSnapAgainstConstraints( instanceName string, revision snap.Revision, @@ -352,8 +370,11 @@ func checkSnapAgainstConstraints( ) error { if constraints.Presence == asserts.PresenceInvalid { verb := "install" - if action == "refresh" { + switch action { + case "refresh": verb = "update" + case "download": + verb = "download" } return fmt.Errorf("cannot %s snap %q due to enforcing rules of validation set %s", @@ -369,8 +390,11 @@ func checkSnapAgainstConstraints( func checkComponentsAgainstConstraints(snapName string, comps map[string]snap.Revision, constraints snapasserts.SnapPresenceConstraints, action string) error { verb := "install" - if action == "refresh" { + switch action { + case "refresh": verb = "update" + case "download": + verb = "download" } for compName, compRevision := range comps { @@ -564,7 +588,10 @@ func completeStoreAction(action *store.SnapAction, revOpts RevisionOptions, igno func invalidRevisionError(action, snapName string, sets []snapasserts.ValidationSetKey, requested, required snap.Revision) error { verb := "install" preposition := "at" - if action == "refresh" { + switch action { + case "download": + verb = "download" + case "refresh": verb = "update" preposition = "to" } @@ -583,7 +610,10 @@ func invalidRevisionError(action, snapName string, sets []snapasserts.Validation func invalidComponentRevisionError(action, snapName, componentName string, sets []snapasserts.ValidationSetKey, requested, required snap.Revision) error { verb := "install" preposition := "at" - if action == "refresh" { + switch action { + case "download": + verb = "download" + case "refresh": verb = "update" preposition = "to" }