diff --git a/overlord/devicestate/devicestate.go b/overlord/devicestate/devicestate.go index a887b5b5af7..8cdf043fd37 100644 --- a/overlord/devicestate/devicestate.go +++ b/overlord/devicestate/devicestate.go @@ -1673,6 +1673,9 @@ type recoverySystemSetup struct { // LocalComponents is a list of components that should be used to create the // recovery system. LocalComponents []snapstate.PathComponent `json:"local-components,omitempty"` + // Allowlist identifies the snaps and components that may be used to create + // the recovery system. + Allowlist *SeedAllowlist `json:"allowlist,omitempty"` // TestSystem is set to true if the new recovery system should // not be verified by rebooting into the new system. Once the system is // created, it will immediately be considered a valid recovery system. @@ -1745,7 +1748,7 @@ func SeedRefreshTasks( return nil, nil, nil } - triggers := seedRefreshTriggers(st, dctx) + triggers, allowlist := seedRefreshPolicy(st, dctx) var snapsups, compsups []string added := make(map[string]bool, len(candidates)) @@ -1766,6 +1769,11 @@ func SeedRefreshTasks( return nil, nil, nil } + seedAllowlist, err := allowlist() + if err != nil { + return nil, nil, err + } + labelBase := timeNow().Format("20060102") label, err := pickRecoverySystemLabel(labelBase) if err != nil { @@ -1773,6 +1781,7 @@ func SeedRefreshTasks( } ts, err := createRecoverySystemTasks(st, label, snapsups, compsups, CreateRecoverySystemOptions{ + Allowlist: &seedAllowlist, TestSystem: true, MarkDefault: true, SeedRefresh: true, @@ -1830,12 +1839,17 @@ func UpdateSeedRefreshChange(seedTS *snapstate.SeedRefreshTasks, dctx snapstate. return false, nil } - triggers := seedRefreshTriggers(seedTS.Create.State(), dctx) - ok, err := triggers(candidate.InstanceName) + setup, err := taskRecoverySystemSetup(seedTS.Create) if err != nil { return false, err } - if !ok { + if setup.Allowlist == nil { + return false, errors.New("internal error: seed-refresh recovery system setup is missing seed allowlist") + } + + // we've already calculated which candidates are allowed to go into the + // seed. avoid opening the seed again by using that list + if !strutil.ListContains(setup.Allowlist.Snaps, candidate.InstanceName) { return false, nil } @@ -1864,7 +1878,7 @@ func appendSeedRefreshCandidate(create *state.Task, snapSetupTasks, compSetupTas // TODO:SEEDREFRESH: remove this once we support seed-refresh seeds // gaining/losing snaps func CheckSeedRefreshRemove(st *state.State, si *snap.Info, dctx snapstate.DeviceContext) error { - triggers := seedRefreshTriggers(st, dctx) + triggers, _ := seedRefreshPolicy(st, dctx) ok, err := triggers(si.SnapName()) if err != nil { return err @@ -1876,69 +1890,139 @@ func CheckSeedRefreshRemove(st *state.State, si *snap.Info, dctx snapstate.Devic return nil } -// seedRefreshTriggers returns a closure that reports whether the given snap -// should trigger a seed refresh. The seed is lazily loaded, and only opened -// when required. -func seedRefreshTriggers(st *state.State, dctx snapstate.DeviceContext) func(string) (bool, error) { - required := make(map[string]bool) - optional := make(map[string]bool) +// seedRefreshPolicy returns closures that define the seed refresh policy. The +// returned triggers function defines which snaps will trigger the seed refresh. +// The allowlist function returns which snaps and components are allowed in the +// final seed. This allowlist is composed of required model snaps/components and +// optional model snaps/components that are present in the current seed. +// +// This function returns closures to help avoid opening the seed in as many +// cases as possible. The closures share the state needed from the seed, so +// we'll open the seed at most once. +func seedRefreshPolicy(st *state.State, dctx snapstate.DeviceContext) (triggers func(string) (bool, error), allowlist func() (SeedAllowlist, error)) { + required := make(map[string]*asserts.ModelSnap) + optional := make(map[string]*asserts.ModelSnap) + for _, sn := range dctx.Model().AllSnaps() { if sn.Presence == "required" { - required[sn.SnapName()] = true + required[sn.SnapName()] = sn } else { - optional[sn.SnapName()] = true + optional[sn.SnapName()] = sn } } - // snapd should always be considered a part of the model. this is really a - // compatibility thing, and maybe should not be here since seed-refresh - // isn't gonna work on old models anyways. - required["snapd"] = true + // snapd is implicitly required, including models that don't list it + required["snapd"] = &asserts.ModelSnap{} - var optionalInSeed map[string]bool + var cachedOptionalContainers *seed.OptionalContainers + readOptionalContainers := func() (seed.OptionalContainers, error) { + if cachedOptionalContainers != nil { + return *cachedOptionalContainers, nil + } - return func(instanceName string) (bool, error) { - if required[instanceName] { + seedOCs, err := currentSeedOptionalContainers(st) + if err != nil { + return seed.OptionalContainers{}, err + } + + cachedOptionalContainers = &seedOCs + return *cachedOptionalContainers, nil + } + + triggers = func(instanceName string) (bool, error) { + if _, ok := required[instanceName]; ok { return true, nil } - if !optional[instanceName] { + if _, ok := optional[instanceName]; !ok { return false, nil } - if optionalInSeed == nil { - currentSystem, err := currentSeededSystem(st) - if err != nil { - return false, err + optionalInSeed, err := readOptionalContainers() + if err != nil { + return false, err + } + + return strutil.ListContains(optionalInSeed.Snaps, instanceName), nil + } + + allowlist = func() (SeedAllowlist, error) { + var snaps []string + components := make(map[string][]string) + + for snapName, modelSnap := range required { + snaps = append(snaps, snapName) + for compName, comp := range modelSnap.Components { + if comp.Presence == "optional" { + optionalInSeed, err := readOptionalContainers() + if err != nil { + return SeedAllowlist{}, err + } + + if !strutil.ListContains(optionalInSeed.Components[snapName], compName) { + continue + } + } + + components[snapName] = append(components[snapName], compName) } + } - current, err := seedOpen(dirs.SnapSeedDir, currentSystem.System) + for snapName, modelSnap := range optional { + optionalInSeed, err := readOptionalContainers() if err != nil { - return false, err - } - if err := current.LoadAssertions(nil, nil); err != nil { - return false, err + return SeedAllowlist{}, err } - copier, ok := current.(seed.Copier) - if !ok { - // this would only happen if the seed is pre-core20 - return false, fmt.Errorf("internal error: seed %q does not support listing optional containers", currentSystem.System) + if !strutil.ListContains(optionalInSeed.Snaps, snapName) { + continue } - oc, err := copier.OptionalContainers() - if err != nil { - return false, err - } + snaps = append(snaps, snapName) + + for compName, comp := range modelSnap.Components { + if comp.Presence == "optional" && !strutil.ListContains(optionalInSeed.Components[snapName], compName) { + continue + } - optionalInSeed = make(map[string]bool, len(oc.Snaps)) - for _, sn := range oc.Snaps { - optionalInSeed[sn] = true + components[snapName] = append(components[snapName], compName) } } - return optionalInSeed[instanceName], nil + sort.Strings(snaps) + for _, comps := range components { + sort.Strings(comps) + } + + return SeedAllowlist{ + Snaps: snaps, + Components: components, + }, nil } + + return triggers, allowlist +} + +func currentSeedOptionalContainers(st *state.State) (seed.OptionalContainers, error) { + currentSystem, err := currentSeededSystem(st) + if err != nil { + return seed.OptionalContainers{}, err + } + + current, err := seedOpen(dirs.SnapSeedDir, currentSystem.System) + if err != nil { + return seed.OptionalContainers{}, err + } + if err := current.LoadAssertions(nil, nil); err != nil { + return seed.OptionalContainers{}, err + } + + copier, ok := current.(seed.Copier) + if !ok { + return seed.OptionalContainers{}, fmt.Errorf("internal error: seed %q does not support listing optional containers", currentSystem.System) + } + + return copier.OptionalContainers() } func findSeedRefreshTasks(ts *state.TaskSet) (*snapstate.SeedRefreshTasks, error) { @@ -2074,6 +2158,7 @@ func createRecoverySystemTasks(st *state.State, label string, snapSetupTasks, co ComponentSetupTasks: compSetupTasks, LocalSnaps: opts.LocalSnaps, LocalComponents: opts.LocalComponents, + Allowlist: opts.Allowlist, TestSystem: opts.TestSystem, MarkDefault: opts.MarkDefault, SeedRefresh: opts.SeedRefresh, @@ -2130,6 +2215,10 @@ type CreateRecoverySystemOptions struct { // recovery system. LocalComponents []snapstate.PathComponent + // Allowlist identifies the snaps and components that may be used to create + // the recovery system. A nil allowlist permits all containers. + Allowlist *SeedAllowlist + // TestSystem is set to true if the new recovery system should be verified // by rebooting into the new system, prior to marking it as a valid recovery // system. If false, the system will immediately be considered a valid @@ -2149,6 +2238,13 @@ type CreateRecoverySystemOptions struct { Offline bool } +// SeedAllowlist identifies the snaps and components that may be used to +// create a recovery system. +type SeedAllowlist struct { + Snaps []string `json:"snaps,omitempty"` + Components map[string][]string `json:"components,omitempty"` +} + var ErrNoRecoverySystem = errors.New("recovery system does not exist") // RemoveRecoverySystem removes the recovery system with the given label. The diff --git a/overlord/devicestate/devicestate_systems_test.go b/overlord/devicestate/devicestate_systems_test.go index 471996fc826..bd40e5cf3be 100644 --- a/overlord/devicestate/devicestate_systems_test.go +++ b/overlord/devicestate/devicestate_systems_test.go @@ -4346,10 +4346,20 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid }) } +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsSeedAllowlistExcludesOptionalSnap(c *C) { + s.testDeviceManagerCreateRecoverySystemValidationSetsHappy(c, testCreateRecoverySystemValidationSetsOptions{ + PreInstallOptionalSnap: true, + Allowlist: &devicestate.SeedAllowlist{ + Snaps: []string{"core20", "pc", "pc-kernel", "snapd"}, + }, + }) +} + type testCreateRecoverySystemValidationSetsOptions struct { MarkDefault bool RequireOptionalSnapInValidationSet bool PreInstallOptionalSnap bool + Allowlist *devicestate.SeedAllowlist } func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValidationSetsHappy(c *C, opts testCreateRecoverySystemValidationSetsOptions) { @@ -4614,6 +4624,7 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid ValidationSets: validationSets, TestSystem: true, MarkDefault: opts.MarkDefault, + Allowlist: opts.Allowlist, }) c.Assert(err, IsNil) c.Assert(chg, NotNil) @@ -4643,7 +4654,7 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid c.Check(s.restartRequests, DeepEquals, []restart.RestartType{restart.RestartSystemNow}) var runModeSnaps []string - if opts.RequireOptionalSnapInValidationSet || opts.PreInstallOptionalSnap { + if opts.RequireOptionalSnapInValidationSet || (opts.PreInstallOptionalSnap && opts.Allowlist == nil) { runModeSnaps = []string{"other-required"} } validateCore20Seed(c, "1234", s.model, s.storeSigning.Trusted, runModeSnaps...) @@ -4671,7 +4682,7 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid // verify that new files are tracked correctly expectedFiles := []string{"snapd_13.snap", "pc-kernel_11.snap", "core20_12.snap", "pc_10.snap"} - if opts.RequireOptionalSnapInValidationSet || opts.PreInstallOptionalSnap { + if opts.RequireOptionalSnapInValidationSet || (opts.PreInstallOptionalSnap && opts.Allowlist == nil) { expectedFiles = append(expectedFiles, "other-required_14.snap") } @@ -6007,6 +6018,58 @@ func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValid }) } +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemSeedAllowlistExcludesOptionalComponents(c *C) { + s.state.Lock() + s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, map[string]snap.Revision{ + "kmod": snap.R(22), + }) + s.makeSnapInState(c, "snap-with-components", snap.R(12), nil, map[string]snap.Revision{ + "comp-1": snap.R(23), + }) + s.state.Unlock() + + s.testDeviceManagerCreateRecoverySystemValidationSetsOfflineWithComponents(c, testCreateRecoverySystemValidationSetsOfflineWithComponents{ + blobs: []string{ + "snapd_4.snap", "pc-kernel-with-kmods_11.snap", "core20_3.snap", "pc_1.snap", "snap-with-components_12.snap", + }, + runModeSnaps: []string{"snap-with-components"}, + snapsToProvide: []string{"pc-kernel-with-kmods"}, + kmodModelPresence: "optional", + kmodVsetPresence: "optional", + allowlist: &devicestate.SeedAllowlist{ + Snaps: []string{"core20", "pc", "pc-kernel-with-kmods", "snap-with-components", "snapd"}, + }, + }) +} + +func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemSeedAllowlistSelectsOptionalComponents(c *C) { + s.state.Lock() + s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, map[string]snap.Revision{ + "kmod": snap.R(22), + }) + s.makeSnapInState(c, "snap-with-components", snap.R(12), nil, map[string]snap.Revision{ + "comp-1": snap.R(23), + }) + s.state.Unlock() + + s.testDeviceManagerCreateRecoverySystemValidationSetsOfflineWithComponents(c, testCreateRecoverySystemValidationSetsOfflineWithComponents{ + blobs: []string{ + "snapd_4.snap", "pc-kernel-with-kmods_11.snap", "pc-kernel-with-kmods+kmod_22.comp", + "core20_3.snap", "pc_1.snap", "snap-with-components_12.snap", + }, + runModeSnaps: []string{"snap-with-components"}, + snapsToProvide: []string{"pc-kernel-with-kmods"}, + kmodModelPresence: "optional", + kmodVsetPresence: "optional", + allowlist: &devicestate.SeedAllowlist{ + Snaps: []string{"core20", "pc", "pc-kernel-with-kmods", "snap-with-components", "snapd"}, + Components: map[string][]string{ + "pc-kernel-with-kmods": {"kmod"}, + }, + }, + }) +} + func (s *deviceMgrSystemsCreateSuite) TestDeviceManagerCreateRecoverySystemValidationSetsOfflineWithComponentsUseLocalOverInstalled(c *C) { s.state.Lock() s.makeSnapInState(c, "pc-kernel-with-kmods", snap.R(11), nil, map[string]snap.Revision{ @@ -6040,6 +6103,7 @@ type testCreateRecoverySystemValidationSetsOfflineWithComponents struct { componentsToProvide []naming.ComponentRef kmodModelPresence string kmodVsetPresence string + allowlist *devicestate.SeedAllowlist } func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValidationSetsOfflineWithComponents(c *C, opts testCreateRecoverySystemValidationSetsOfflineWithComponents) { @@ -6229,6 +6293,7 @@ func (s *deviceMgrSystemsCreateSuite) testDeviceManagerCreateRecoverySystemValid ValidationSets: []*asserts.ValidationSet{vset.(*asserts.ValidationSet)}, LocalSnaps: localSnaps, LocalComponents: localComponents, + Allowlist: opts.allowlist, Offline: true, TestSystem: true, MarkDefault: true, diff --git a/overlord/devicestate/devicestate_test.go b/overlord/devicestate/devicestate_test.go index ea31bc48d1b..905fda5c84b 100644 --- a/overlord/devicestate/devicestate_test.go +++ b/overlord/devicestate/devicestate_test.go @@ -2218,6 +2218,39 @@ func (s *deviceMgrSuite) TestCreateSeedRefreshTasksSkipsOpeningSeed(c *C) { c.Assert(err, IsNil) c.Assert(seedTS, NotNil) c.Assert(added, DeepEquals, map[string]bool{"snap-1": true}) + + var setup devicestate.RecoverySystemSetup + c.Assert(seedTS.Create.Get("recovery-system-setup", &setup), IsNil) + c.Check(setup.Allowlist, DeepEquals, &devicestate.SeedAllowlist{ + Snaps: []string{"core24", "pc", "pc-kernel", "snap-1", "snapd"}, + }) +} + +func (s *deviceMgrSuite) TestCreateSeedRefreshTasksSkipsOpeningSeedForNonModelCandidates(c *C) { + s.state.Lock() + defer s.state.Unlock() + + dctx := s.setupSeedRefreshSeedAndContext(c, []map[string]string{ + {"name": "snapd", "type": "snapd"}, + {"name": "core24", "type": "base", "default-channel": "24"}, + {"name": "pc-kernel", "type": "kernel", "default-channel": "24"}, + {"name": "pc", "type": "gadget", "default-channel": "24"}, + {"name": "optional-snap", "presence": "optional"}, + }) + + restore := devicestate.MockSeedOpen(func(seedDir, label string) (seed.Seed, error) { + c.Fatalf("unexpected call to seed.Open: %s %s", seedDir, label) + return nil, nil + }) + defer restore() + + seedTS, added, err := devicestate.SeedRefreshTasks(s.state, dctx, []snapstate.SeedRefreshCandidate{{ + InstanceName: "not-in-model", + SnapSetupTaskIDs: []string{s.state.NewTask("fake-download", "...").ID()}, + }}, snapstate.SeedRefreshEvictionPolicy{SeedsToRetain: 1}) + c.Assert(err, IsNil) + c.Check(seedTS, IsNil) + c.Check(added, IsNil) } func (s *deviceMgrSuite) TestCreateSeedRefreshTasksSkipsRemodeling(c *C) { @@ -2294,9 +2327,12 @@ func (s *deviceMgrSuite) TestCreateSeedRefreshTasks(c *C) { "directory": filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", expectedLabel), "snap-setup-tasks": []any{tSnap1.ID(), tSnap2.ID()}, "component-setup-tasks": []any{tComp1.ID()}, - "mark-default": true, - "seed-refresh": true, - "test-system": true, + "allowlist": map[string]any{ + "snaps": []any{"core24", "pc", "pc-kernel", "snap-1", "snap-2", "snapd"}, + }, + "mark-default": true, + "seed-refresh": true, + "test-system": true, }) var setupTaskID string @@ -2308,6 +2344,89 @@ func (s *deviceMgrSuite) TestCreateSeedRefreshTasks(c *C) { c.Check(boundary, Equals, restart.RestartBoundaryDirectionDo) } +func (s *deviceMgrSuite) TestCreateSeedRefreshTasksAllowlistUsesCurrentSeedOptionals(c *C) { + s.state.Lock() + defer s.state.Unlock() + + // set up the current seeded-system entry. seed.Open is replaced below so + // that optional snap and component membership can be controlled directly. + s.setupSeedRefreshSeedAndContext(c, []map[string]string{ + {"name": "snapd", "type": "snapd"}, + {"name": "core24", "type": "base", "default-channel": "24"}, + {"name": "pc-kernel", "type": "kernel", "default-channel": "24"}, + {"name": "pc", "type": "gadget", "default-channel": "24"}, + }) + + model := s.brands.Model("my-brand", "seed-refresh-model", map[string]any{ + "architecture": "amd64", + "base": "core24", + "grade": "dangerous", + "snaps": []any{ + map[string]any{"name": "snapd", "type": "snapd"}, + map[string]any{"name": "core24", "type": "base"}, + map[string]any{"name": "pc-kernel", "type": "kernel"}, + map[string]any{"name": "pc", "type": "gadget"}, + map[string]any{ + "name": "required-app", + "components": map[string]any{ + "required-comp": "required", + "optional-present-comp": "optional", + "optional-absent-comp": "optional", + }, + }, + map[string]any{ + "name": "optional-present", + "presence": "optional", + "components": map[string]any{ + "required-comp": "required", + "optional-present-comp": "optional", + "optional-absent-comp": "optional", + }, + }, + map[string]any{ + "name": "optional-absent", + "presence": "optional", + "components": map[string]any{"required-comp": "required"}, + }, + }, + }) + + seedOpenCalls := 0 + restore := devicestate.MockSeedOpen(func(seedDir, label string) (seed.Seed, error) { + seedOpenCalls++ + return &fakeSeedCopier{ + fakeSeed: fakeSeed{}, + optionalContainers: seed.OptionalContainers{ + Snaps: []string{"optional-present"}, + Components: map[string][]string{ + "required-app": {"optional-present-comp"}, + "optional-present": {"optional-present-comp"}, + }, + }, + }, nil + }) + defer restore() + + requiredTask := s.state.NewTask("fake-download", "...") + seedTS, added, err := devicestate.SeedRefreshTasks(s.state, &snapstatetest.TrivialDeviceContext{DeviceModel: model}, []snapstate.SeedRefreshCandidate{{ + InstanceName: "required-app", SnapSetupTaskIDs: []string{requiredTask.ID()}, + }}, snapstate.SeedRefreshEvictionPolicy{SeedsToRetain: 1}) + c.Assert(err, IsNil) + c.Assert(seedTS, NotNil) + c.Check(added, DeepEquals, map[string]bool{"required-app": true}) + c.Check(seedOpenCalls, Equals, 1) + + var setup devicestate.RecoverySystemSetup + c.Assert(seedTS.Create.Get("recovery-system-setup", &setup), IsNil) + c.Check(setup.Allowlist, DeepEquals, &devicestate.SeedAllowlist{ + Snaps: []string{"core24", "optional-present", "pc", "pc-kernel", "required-app", "snapd"}, + Components: map[string][]string{ + "optional-present": {"optional-present-comp", "required-comp"}, + "required-app": {"optional-present-comp", "required-comp"}, + }, + }) +} + func (s *deviceMgrSuite) TestCreateSeedRefreshTasksComponentExclusive(c *C) { s.state.Lock() defer s.state.Unlock() @@ -2501,6 +2620,11 @@ func (s *deviceMgrSuite) TestUpdateSeedRefreshChange(c *C) { {"name": "snap-1"}, {"name": "snap-2", "presence": "optional"}, }, "snap-2", "snap-3") + restore := devicestate.MockSeedOpen(func(seedDir, label string) (seed.Seed, error) { + c.Fatalf("unexpected call to seed.Open: %s %s", seedDir, label) + return nil, nil + }) + defer restore() snap1Task := s.state.NewTask("fake-download", "...") snap2Task := s.state.NewTask("fake-download", "...") snap3Task := s.state.NewTask("fake-download", "...") @@ -2510,6 +2634,7 @@ func (s *deviceMgrSuite) TestUpdateSeedRefreshChange(c *C) { Directory: filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "20260227"), SnapSetupTasks: []string{snap1Task.ID()}, ComponentSetupTasks: []string{"comp-1"}, + Allowlist: &devicestate.SeedAllowlist{Snaps: []string{"snap-1", "snap-2"}}, }) finalize := s.state.NewTask("finalize-recovery-system", "...") finalize.Set("recovery-system-setup-task", create.ID()) @@ -2552,6 +2677,33 @@ func (s *deviceMgrSuite) TestUpdateSeedRefreshChange(c *C) { c.Assert(create.Get("recovery-system-setup", &setup), IsNil) c.Check(setup.SnapSetupTasks, DeepEquals, []string{snap1Task.ID(), snap2Task.ID()}) c.Check(setup.ComponentSetupTasks, DeepEquals, []string{"comp-1", "comp-2", "comp-3"}) + c.Check(setup.Allowlist, DeepEquals, &devicestate.SeedAllowlist{Snaps: []string{"snap-1", "snap-2"}}) +} + +func (s *deviceMgrSuite) TestUpdateSeedRefreshChangeErrorsWithoutSeedAllowlist(c *C) { + s.state.Lock() + defer s.state.Unlock() + + create := s.state.NewTask("create-recovery-system", "...") + create.Set("recovery-system-setup", &devicestate.RecoverySystemSetup{ + Label: "20260227", + Directory: filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "20260227"), + SnapSetupTasks: []string{"existing-snap-task"}, + }) + finalize := s.state.NewTask("finalize-recovery-system", "...") + finalize.Set("recovery-system-setup-task", create.ID()) + seedTS := &snapstate.SeedRefreshTasks{Create: create, Finalize: finalize} + + added, err := devicestate.UpdateSeedRefreshChange(seedTS, &snapstatetest.TrivialDeviceContext{}, snapstate.SeedRefreshCandidate{ + InstanceName: "snap-1", + SnapSetupTaskIDs: []string{"new-snap-task"}, + }) + c.Assert(err, ErrorMatches, `internal error: seed-refresh recovery system setup is missing seed allowlist`) + c.Check(added, Equals, false) + + var setup devicestate.RecoverySystemSetup + c.Assert(create.Get("recovery-system-setup", &setup), IsNil) + c.Check(setup.SnapSetupTasks, DeepEquals, []string{"existing-snap-task"}) } func (s *deviceMgrSuite) TestUpdateSeedRefreshChangeSkipsRemodeling(c *C) { @@ -2618,6 +2770,7 @@ func (s *deviceMgrSuite) TestUpdateSeedRefreshChangeComponentExclusive(c *C) { Directory: filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "20260227"), SnapSetupTasks: []string{snap1Task.ID()}, ComponentSetupTasks: []string{compTask1.ID()}, + Allowlist: &devicestate.SeedAllowlist{Snaps: []string{"snap-1", "snap-2"}}, }) finalize := s.state.NewTask("finalize-recovery-system", "...") finalize.Set("recovery-system-setup-task", create.ID()) @@ -2681,6 +2834,7 @@ func (s *deviceMgrSuite) TestUpdateSeedRefreshChangeSkipsOptionalSnapNotInCurren Label: "20260227", Directory: filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "20260227"), SnapSetupTasks: []string{snap1Task.ID()}, + Allowlist: &devicestate.SeedAllowlist{Snaps: []string{"snap-1"}}, }) finalize := s.state.NewTask("finalize-recovery-system", "...") finalize.Set("recovery-system-setup-task", create.ID()) @@ -2768,6 +2922,7 @@ func (s *deviceMgrSuite) TestUpdateSeedRefreshChangeUsesPendingSeedRefreshTasks( Label: "20260227", Directory: filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", "20260227"), SnapSetupTasks: []string{currentSnapTask.ID()}, + Allowlist: &devicestate.SeedAllowlist{Snaps: []string{"snap-1", "snap-2"}}, }) currentFinalize := s.state.NewTask("finalize-recovery-system", "...") currentFinalize.Set("recovery-system-setup-task", currentCreate.ID()) diff --git a/overlord/devicestate/systems.go b/overlord/devicestate/systems.go index 3208b946d05..d674af6629a 100644 --- a/overlord/devicestate/systems.go +++ b/overlord/devicestate/systems.go @@ -236,6 +236,12 @@ type setupInfoGetter struct { } func (ig *setupInfoGetter) ComponentInfo(st *state.State, cref naming.ComponentRef, snapInfo *snap.Info) (info *snap.ComponentInfo, path string, present bool, err error) { + if allowlist := ig.setup.Allowlist; allowlist != nil { + if !strutil.ListContains(allowlist.Components[cref.SnapName], cref.ComponentName) { + return nil, "", false, nil + } + } + // components will come from one of these places: // * passed into the task via a list of side infos (these would have // come from a user posting snaps via the API) @@ -319,6 +325,12 @@ func (ig *setupInfoGetter) ComponentInfo(st *state.State, cref naming.ComponentR } func (ig *setupInfoGetter) SnapInfo(st *state.State, name string) (info *snap.Info, path string, present bool, err error) { + if allowlist := ig.setup.Allowlist; allowlist != nil { + if !strutil.ListContains(allowlist.Snaps, name) { + return nil, "", false, nil + } + } + // snaps will come from one of these places: // * passed into the task via a list of side infos (these would have // come from a user posting snaps via the API) diff --git a/tests/nested/manual/seed-refresh/model.json.in b/tests/nested/manual/seed-refresh/model.json.in new file mode 100644 index 00000000000..0cc6798ceb5 --- /dev/null +++ b/tests/nested/manual/seed-refresh/model.json.in @@ -0,0 +1,61 @@ +{ + "type": "model", + "authority-id": "developer1", + "series": "16", + "brand-id": "developer1", + "model": "testkeys-seed-refresh-@@BASE@@-amd64", + "architecture": "amd64", + "timestamp": "2026-04-06T00:00:00+00:00", + "grade": "dangerous", + "base": "@@BASE@@", + "serial-authority": [ + "generic" + ], + "snaps": [ + { + "default-channel": "@@VERSION@@/edge", + "id": "UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH", + "name": "pc", + "type": "gadget" + }, + { + "default-channel": "@@VERSION@@/edge", + "id": "pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza", + "name": "pc-kernel", + "type": "kernel" + }, + { + "default-channel": "latest/edge", + "id": "@@BASE_ID@@", + "name": "@@BASE@@", + "type": "base" + }, + { + "default-channel": "latest/edge", + "id": "PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4", + "name": "snapd", + "type": "snapd" + }, + { + "default-channel": "latest/edge", + "id": "N6knHcRR3i01MZ923MgY2pmeOiGCdLNR", + "name": "@@REQUIRED_SNAP@@", + "type": "app", + "modes": [ + "run", + "ephemeral" + ] + }, + { + "default-channel": "latest/edge", + "id": "7uJy5cKAtD2nYbqANIbMy0ssM0RJvJ1O", + "name": "test-snapd-seed-optional", + "presence": "optional", + "type": "app", + "modes": [ + "run", + "ephemeral" + ] + } + ] +} diff --git a/tests/nested/manual/seed-refresh/optional-snap/meta/snap.yaml.in b/tests/nested/manual/seed-refresh/optional-snap/meta/snap.yaml.in new file mode 100644 index 00000000000..4f7ef64a411 --- /dev/null +++ b/tests/nested/manual/seed-refresh/optional-snap/meta/snap.yaml.in @@ -0,0 +1,8 @@ +name: test-snapd-seed-optional +version: 1.0 +summary: Optional snap for seed-refresh tests +description: An optional snap that must not be added to a refreshed seed. +architectures: [all] +base: @@BASE@@ +grade: devel +confinement: strict diff --git a/tests/nested/manual/seed-refresh/task.yaml b/tests/nested/manual/seed-refresh/task.yaml index fca7d223899..b005818a99c 100644 --- a/tests/nested/manual/seed-refresh/task.yaml +++ b/tests/nested/manual/seed-refresh/task.yaml @@ -5,13 +5,14 @@ details: | change on Ubuntu Core. The test refreshes the kernel, base, and a non-essential model snap, checks that the refreshed recovery system is recorded, and verifies the refreshed recovery system boots with the refreshed - snaps. + snaps. It also verifies that an installed optional snap that is absent from + the original seed is not added to the refreshed seed. systems: - -ubuntu-1* environment: - NESTED_CUSTOM_MODEL: $TESTSLIB/assertions/developer1-{VERSION}-with-app-dangerous.model + NESTED_CUSTOM_MODEL: $(pwd)/seed-refresh.model NESTED_ENABLE_TPM: true NESTED_ENABLE_SECURE_BOOT: true NESTED_BUILD_SNAPD_FROM_CURRENT: true @@ -41,12 +42,40 @@ prepare: | version="$(tests.nested show version)" base_snap="core${version}" test_snap_name="test-snapd-sh-core${version}" - model_json="$TESTSLIB/assertions/developer1-${version}-with-app-dangerous.json" + optional_snap_name="test-snapd-seed-optional" + + case "${version}" in + 20) base_id="DLqre5XGLbDqg9jPtiAhRRjDuPVa5X1q" ;; + 22) base_id="amcUKQILKXHHTlmSa7NMdnXSx02dNeeT" ;; + 24) base_id="dwTAh7MZZ01zyriOZErqd1JynQLiOGvM" ;; + 26) base_id="cUqM61hRuZAJYmIS898Ux66VY61gBbZf" ;; + *) + echo "unsupported Core version: ${version}" + exit 1 + ;; + esac + + model_json="$(pwd)/model.json" + sed \ + -e "s/@@VERSION@@/${version}/g" \ + -e "s/@@BASE@@/${base_snap}/g" \ + -e "s/@@BASE_ID@@/${base_id}/g" \ + -e "s/@@REQUIRED_SNAP@@/${test_snap_name}/g" \ + model.json.in > "${model_json}" + test_snap_id="$(gojq -r --arg name "${test_snap_name}" ".snaps[] | select(.name == \$name) | .id" "${model_json}")" + optional_snap_id="$(gojq -r --arg name "${optional_snap_name}" ".snaps[] | select(.name == \$name) | .id" "${model_json}")" test -n "${test_snap_id}" + test -n "${optional_snap_id}" + + gendeveloper1 sign-model < "${model_json}" > seed-refresh.model test_snap_path="$("${TESTSTOOLS}/snaps-state" pack-local "${test_snap_name}")" + sed "s/@@BASE@@/${base_snap}/g" optional-snap/meta/snap.yaml.in > optional-snap/meta/snap.yaml + snap pack --filename="${optional_snap_name}.snap" optional-snap + optional_snap_path="$(pwd)/${optional_snap_name}.snap" + tests.nested prepare-essential-snaps extra_snaps="$(tests.nested get extra-snaps-path)" @@ -59,10 +88,16 @@ prepare: | "${test_snap_path}" \ "${test_snap_id}" + "${TESTSTOOLS}/store-state" make-snap-installable --noack \ + --revision 1 \ + "${NESTED_FAKESTORE_BLOB_DIR}" \ + "${optional_snap_path}" \ + "${optional_snap_id}" + snapd_snap="$(echo "${extra_snaps}"/snapd_*.snap)" export SNAPPY_FORCE_API_URL="${NESTED_UBUNTU_IMAGE_SNAPPY_FORCE_SAS_URL}" - ubuntu-image snap --image-size 10G "$TESTSLIB/assertions/developer1-${version}-with-app-dangerous.model" \ + ubuntu-image snap --image-size 10G seed-refresh.model \ --snap "${extra_snaps}/pc.snap" \ --snap "${extra_snaps}/pc-kernel.snap" \ --snap "${snapd_snap}" \ @@ -101,14 +136,17 @@ execute: | version="$(tests.nested show version)" base_snap="core${version}" test_snap_name="test-snapd-sh-core${version}" - model_json="$TESTSLIB/assertions/developer1-${version}-with-app-dangerous.json" + optional_snap_name="test-snapd-seed-optional" + model_json="$(pwd)/model.json" base_id="$(gojq -r --arg name "${base_snap}" ".snaps[] | select(.name == \$name) | .id" "${model_json}")" kernel_id="$(gojq -r '.snaps[] | select(.name == "pc-kernel") | .id' "${model_json}")" test_snap_id="$(gojq -r --arg name "${test_snap_name}" ".snaps[] | select(.name == \$name) | .id" "${model_json}")" + optional_snap_id="$(gojq -r --arg name "${optional_snap_name}" ".snaps[] | select(.name == \$name) | .id" "${model_json}")" test_snap_path="$("${TESTSTOOLS}/snaps-state" pack-local "${test_snap_name}")" test -n "${base_id}" test -n "${kernel_id}" test -n "${test_snap_id}" + test -n "${optional_snap_id}" make_fake_store_revision() { local source_snap="$1" @@ -182,10 +220,27 @@ execute: | done } + assert_seed_snap_absent() { + local system_label="$1" + local assertions="/run/mnt/ubuntu-seed/systems/${system_label}/assertions/snaps" + + remote.exec "sudo snap debug api /v2/systems/${system_label}" > system-details.json + test "$(gojq --arg snap "${optional_snap_name}" "[.result[\"available-optional\"].snaps[]? | select(. == \$snap)] | length" system-details.json)" = 0 + remote.exec "! test -e /run/mnt/ubuntu-seed/snaps/${optional_snap_name}_*.snap" + remote.exec "! sudo grep -Fxq 'snap-id: ${optional_snap_id}' ${assertions}" + } + + remote.exec "sudo snap install ${optional_snap_name}" + assert_live_snap_revisions "${optional_snap_name}=1" + remote.exec 'sudo cat /var/lib/snapd/state.json' | gojq '.data["seeded-systems"]' > seeded-systems-before.json orig_seed_label="$(gojq -r '.[0].system' seeded-systems-before.json)" latest_seed_label="" + # the optional snap is installed on the run system but was not included in + # the original seed + assert_seed_snap_absent "${orig_seed_label}" + remote.exec 'sudo snap set core experimental.seed-refresh=true' revision=2 @@ -211,6 +266,7 @@ execute: | # verify the refreshed snaps are present in the seed remote.exec "test -d /run/mnt/ubuntu-seed/systems/${latest_seed_label}" + assert_seed_snap_absent "${latest_seed_label}" assert_seed_snap_revisions_present "${latest_seed_label}" "${base_snap}=${revision}" "pc-kernel=${revision}" "${test_snap_name}=${revision}" assert_seed_snap_revisions_present "${orig_seed_label}" "${base_snap}=1" pc-kernel=1 "${test_snap_name}=1"