Skip to content

Commit 5d9bb0c

Browse files
committed
o/configstate, o/snapstate: make disk-space checks respect system.disk-space-reservation option
1 parent 2516144 commit 5d9bb0c

9 files changed

Lines changed: 370 additions & 18 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// -*- Mode: Go; indent-tabs-mode: t -*-
2+
//go:build !nomanagers
3+
4+
/*
5+
* Copyright (C) 2026 Canonical Ltd
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License version 3 as
9+
* published by the Free Software Foundation.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*
19+
*/
20+
21+
package configcore
22+
23+
import "github.com/snapcore/snapd/strutil"
24+
25+
func init() {
26+
supportedConfigurations["core.system.disk-space-reservation"] = true
27+
}
28+
29+
func validateDiskSpaceReservation(tr RunTransaction) error {
30+
reservation, err := coreCfg(tr, "system.disk-space-reservation")
31+
if err != nil {
32+
return err
33+
}
34+
if reservation == "" {
35+
return nil
36+
}
37+
_, err = strutil.ParseByteSize(reservation)
38+
return err
39+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// -*- Mode: Go; indent-tabs-mode: t -*-
2+
//go:build !nomanagers
3+
4+
/*
5+
* Copyright (C) 2026 Canonical Ltd
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License version 3 as
9+
* published by the Free Software Foundation.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*
19+
*/
20+
21+
package configcore_test
22+
23+
import (
24+
. "gopkg.in/check.v1"
25+
26+
"github.com/snapcore/snapd/overlord/configstate/configcore"
27+
)
28+
29+
type diskSpaceSuite struct {
30+
configcoreSuite
31+
}
32+
33+
var _ = Suite(&diskSpaceSuite{})
34+
35+
func (s *diskSpaceSuite) TestConfigureDiskSpaceReservation(c *C) {
36+
for _, tc := range []struct {
37+
value any
38+
err string
39+
}{
40+
{value: ""},
41+
{value: "0B"},
42+
{value: "5242880B"},
43+
{value: "5MB"},
44+
{value: "1GB"},
45+
{value: "5", err: `cannot parse "5": need a number with a unit as input`},
46+
{value: "-1B", err: `cannot parse "-1B": size cannot be negative`},
47+
{value: "bad", err: `cannot parse "bad": no numerical prefix`},
48+
{value: "5MiB", err: `cannot parse "5MiB": try 'kB' or 'MB'`},
49+
} {
50+
err := configcore.Run(classicDev, &mockConf{
51+
state: s.state,
52+
changes: map[string]any{
53+
"system.disk-space-reservation": tc.value,
54+
},
55+
})
56+
if tc.err != "" {
57+
c.Check(err, ErrorMatches, tc.err)
58+
} else {
59+
c.Check(err, IsNil)
60+
}
61+
}
62+
}

overlord/configstate/configcore/runwithstate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func init() {
6060
addWithStateHandler(validateRefreshSchedule, nil, validateOnly)
6161
addWithStateHandler(validateRefreshRateLimit, nil, validateOnly)
6262
addWithStateHandler(validateAutomaticSnapshotsExpiration, nil, validateOnly)
63+
addWithStateHandler(validateDiskSpaceReservation, nil, validateOnly)
6364

6465
// netplan.*
6566
addWithStateHandler(validateNetplanSettings, handleNetplanConfiguration, coreOnly)

overlord/snapstate/autorefresh_gating_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2274,7 +2274,7 @@ func (s *snapmgrTestSuite) testAutoRefreshPhase2DiskSpaceCheck(c *C, fail bool)
22742274
defer st.Unlock()
22752275

22762276
restore := snapstate.MockOsutilCheckFreeSpace(func(path string, sz uint64) error {
2277-
c.Check(sz, Equals, snapstate.SafetyMarginDiskSpace(123))
2277+
c.Check(sz, Equals, uint64(123)+snapstate.DefaultDiskSpaceReservation)
22782278
if fail {
22792279
return &osutil.NotEnoughDiskSpaceError{}
22802280
}

overlord/snapstate/export_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ var (
129129

130130
HasOtherInstances = hasOtherInstances
131131

132-
SafetyMarginDiskSpace = safetyMarginDiskSpace
132+
DefaultDiskSpaceReservation = defaultDiskSpaceReservation
133133

134134
AffectedByRefresh = affectedByRefresh
135135

overlord/snapstate/snapstate.go

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ var ErrNothingToDo = errors.New("nothing to do")
9191

9292
var osutilCheckFreeSpace = osutil.CheckFreeSpace
9393

94+
const defaultDiskSpaceReservation = uint64(5 * 1024 * 1024)
95+
9496
// TestingLeaveOutKernelUpdateGadgetAssets can be used to simulate an upgrade
9597
// from a broken snapd that does not generate a "update-gadget-assets" task.
9698
// See LP:#1940553
@@ -172,9 +174,23 @@ func ShouldSendNotificationsToTheUser(st *state.State) (bool, error) {
172174
return true, nil
173175
}
174176

175-
// safetyMarginDiskSpace returns size plus a safety margin (5Mb)
176-
func safetyMarginDiskSpace(size uint64) uint64 {
177-
return size + 5*1024*1024
177+
func diskSpaceReservation(tr *config.Transaction) uint64 {
178+
var reservation string
179+
err := tr.Get("core", "system.disk-space-reservation", &reservation)
180+
if config.IsNoOption(err) {
181+
// TODO: decide how unset system.disk-space-reservation should behave when
182+
// the experimental disk space feature flags are graduated.
183+
return defaultDiskSpaceReservation
184+
}
185+
if err != nil {
186+
return 0
187+
}
188+
189+
parsedReservation, err := strutil.ParseByteSize(reservation)
190+
if err != nil {
191+
return 0
192+
}
193+
return uint64(parsedReservation)
178194
}
179195

180196
// ConfigureSnap returns a set of tasks to configure snapName as done during installation/refresh.
@@ -891,7 +907,7 @@ func downloadTasks(
891907
if !skipSnapDownload {
892908
// TODO:COMPS: support checking for available space for components
893909
toDownloadTo := filepath.Dir(snapsup.BlobPath())
894-
if err := checkDiskSpaceDownload([]minimalInstallInfo{installSnapInfo{info}}, toDownloadTo); err != nil {
910+
if err := checkDiskSpaceDownload(st, []minimalInstallInfo{installSnapInfo{info}}, toDownloadTo); err != nil {
895911
return nil, nil, err
896912
}
897913

@@ -2506,13 +2522,18 @@ func autoRefreshPhase2(st *state.State, candidates []*refreshCandidate, flags *F
25062522
return updateTss, nil
25072523
}
25082524

2509-
func checkDiskSpaceDownload(infos []minimalInstallInfo, rootDir string) error {
2525+
func checkDiskSpaceDownload(st *state.State, infos []minimalInstallInfo, rootDir string) error {
2526+
reservation := diskSpaceReservation(config.NewTransaction(st))
2527+
if reservation == 0 {
2528+
return nil
2529+
}
2530+
25102531
var totalSize uint64
25112532
for _, info := range infos {
25122533
totalSize += uint64(info.DownloadSize())
25132534
}
25142535

2515-
return checkForAvailableSpace(totalSize, infos, "download", rootDir)
2536+
return checkForAvailableSpace(totalSize, reservation, infos, "download", rootDir)
25162537
}
25172538

25182539
// checkDiskSpace checks if there is enough space for the requested snaps and their prerequisites
@@ -2538,16 +2559,21 @@ func checkDiskSpace(st *state.State, changeKind string, infos []minimalInstallIn
25382559
return nil
25392560
}
25402561

2562+
reservation := diskSpaceReservation(tr)
2563+
if reservation == 0 {
2564+
return nil
2565+
}
2566+
25412567
totalSize, err := installSize(st, infos, userID, prqt)
25422568
if err != nil {
25432569
return err
25442570
}
25452571

2546-
return checkForAvailableSpace(totalSize, infos, changeKind, dirs.SnapdStateDir(dirs.GlobalRootDir))
2572+
return checkForAvailableSpace(totalSize, reservation, infos, changeKind, dirs.SnapdStateDir(dirs.GlobalRootDir))
25472573
}
25482574

2549-
func checkForAvailableSpace(totalSize uint64, infos []minimalInstallInfo, changeKind string, rootDir string) error {
2550-
requiredSpace := safetyMarginDiskSpace(totalSize)
2575+
func checkForAvailableSpace(totalSize, reservation uint64, infos []minimalInstallInfo, changeKind string, rootDir string) error {
2576+
requiredSpace := totalSize + reservation
25512577
if err := osutilCheckFreeSpace(rootDir, requiredSpace); err != nil {
25522578
snaps := make([]string, len(infos))
25532579
for i, up := range infos {
@@ -3144,7 +3170,12 @@ func Remove(st *state.State, name string, revision snap.Revision, flags *RemoveF
31443170
// removeTasks() checks check-disk-space-remove feature flag, so snapshotSize
31453171
// will only be greater than 0 if the feature is enabled.
31463172
if snapshotSize > 0 {
3147-
requiredSpace := safetyMarginDiskSpace(snapshotSize)
3173+
reservation := diskSpaceReservation(config.NewTransaction(st))
3174+
if reservation == 0 {
3175+
return ts, err
3176+
}
3177+
3178+
requiredSpace := snapshotSize + reservation
31483179
path := dirs.SnapdStateDir(dirs.GlobalRootDir)
31493180
if err := osutilCheckFreeSpace(path, requiredSpace); err != nil {
31503181
if _, ok := err.(*osutil.NotEnoughDiskSpaceError); ok {
@@ -3296,7 +3327,7 @@ func removeTasks(st *state.State, snapst *SnapState, removals map[string]bool, r
32963327
if err != nil && !config.IsNoOption(err) {
32973328
return nil, 0, err
32983329
}
3299-
if checkDiskSpaceRemove {
3330+
if checkDiskSpaceRemove && diskSpaceReservation(tr) != 0 {
33003331
snapshotSize, err = EstimateSnapshotSize(st, instanceName, nil)
33013332
if err != nil {
33023333
return nil, 0, err
@@ -3579,7 +3610,12 @@ func RemoveMany(st *state.State, names []string, flags *RemoveFlags) ([]string,
35793610
// removeTasks() checks check-disk-space-remove feature flag, so totalSnapshotsSize
35803611
// will only be greater than 0 if the feature is enabled.
35813612
if totalSnapshotsSize > 0 {
3582-
requiredSpace := safetyMarginDiskSpace(totalSnapshotsSize)
3613+
reservation := diskSpaceReservation(config.NewTransaction(st))
3614+
if reservation == 0 {
3615+
return removed, tasksets, nil
3616+
}
3617+
3618+
requiredSpace := totalSnapshotsSize + reservation
35833619
if err := osutilCheckFreeSpace(path, requiredSpace); err != nil {
35843620
if _, ok := err.(*osutil.NotEnoughDiskSpaceError); ok {
35853621
return nil, nil, &InsufficientSpaceError{

overlord/snapstate/snapstate_install_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3766,6 +3766,45 @@ func (s *snapmgrTestSuite) TestInstallDiskSpaceError(c *C) {
37663766
c.Check(diskSpaceErr.Snaps, DeepEquals, []string{"some-snap"})
37673767
}
37683768

3769+
func (s *snapmgrTestSuite) TestInstallConfigureDiskSpaceReservation(c *C) {
3770+
const freeDiskSpace = uint64(1500)
3771+
var requiredSizes []uint64
3772+
restore := snapstate.MockOsutilCheckFreeSpace(func(path string, sz uint64) error {
3773+
c.Check(path, Equals, filepath.Join(dirs.GlobalRootDir, "/var/lib/snapd"))
3774+
requiredSizes = append(requiredSizes, sz)
3775+
if sz > freeDiskSpace {
3776+
return &osutil.NotEnoughDiskSpaceError{}
3777+
}
3778+
return nil
3779+
})
3780+
defer restore()
3781+
3782+
restore = snapstate.MockInstallSize(func(st *state.State, snaps []snapstate.MinimalInstallInfo, userID int, prqt snapstate.PrereqTracker) (uint64, error) {
3783+
return 123, nil
3784+
})
3785+
defer restore()
3786+
3787+
s.state.Lock()
3788+
defer s.state.Unlock()
3789+
3790+
tr := config.NewTransaction(s.state)
3791+
tr.Set("core", "experimental.check-disk-space-install", true)
3792+
tr.Set("core", "system.disk-space-reservation", "2kB")
3793+
tr.Commit()
3794+
3795+
opts := &snapstate.RevisionOptions{Channel: "some-channel"}
3796+
_, err := snapstate.Install(context.Background(), s.state, "some-snap", opts, s.user.ID, snapstate.Flags{})
3797+
c.Assert(err, FitsTypeOf, &snapstate.InsufficientSpaceError{})
3798+
3799+
tr = config.NewTransaction(s.state)
3800+
tr.Set("core", "system.disk-space-reservation", "1kB")
3801+
tr.Commit()
3802+
3803+
_, err = snapstate.Install(context.Background(), s.state, "some-snap", opts, s.user.ID, snapstate.Flags{})
3804+
c.Assert(err, IsNil)
3805+
c.Check(requiredSizes, DeepEquals, []uint64{2123, 1123})
3806+
}
3807+
37693808
func (s *snapmgrTestSuite) TestInstallSizeError(c *C) {
37703809
restore := snapstate.MockInstallSize(func(st *state.State, snaps []snapstate.MinimalInstallInfo, userID int, prqt snapstate.PrereqTracker) (uint64, error) {
37713810
return 0, fmt.Errorf("boom")

0 commit comments

Comments
 (0)