Skip to content

Commit ead5d37

Browse files
committed
feat: add disk space pre-check for build and pull operations
Check available disk space before writing to local storage. When space is insufficient (with 10% safety margin), log a warning but continue the operation without returning an error. Signed-off-by: Zhao Chen <winters.zc@antgroup.com>
1 parent 5da6da2 commit ead5d37

5 files changed

Lines changed: 246 additions & 2 deletions

File tree

pkg/backend/backend.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ type Backend interface {
7070

7171
// backend is the implementation of Backend.
7272
type backend struct {
73-
store storage.Storage
73+
store storage.Storage
74+
storageDir string
7475
}
7576

7677
// New creates a new backend.
@@ -81,6 +82,7 @@ func New(storageDir string) (Backend, error) {
8182
}
8283

8384
return &backend{
84-
store: store,
85+
store: store,
86+
storageDir: storageDir,
8587
}, nil
8688
}

pkg/backend/build.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/modelpack/modctl/pkg/backend/build/hooks"
3535
"github.com/modelpack/modctl/pkg/backend/processor"
3636
"github.com/modelpack/modctl/pkg/config"
37+
"github.com/modelpack/modctl/pkg/diskspace"
3738
"github.com/modelpack/modctl/pkg/modelfile"
3839
"github.com/modelpack/modctl/pkg/source"
3940
)
@@ -67,6 +68,14 @@ func (b *backend) Build(ctx context.Context, modelfilePath, workDir, target stri
6768
return fmt.Errorf("failed to get source info: %w", err)
6869
}
6970

71+
// Check disk space before building (only for local output).
72+
if !cfg.OutputRemote {
73+
totalSize := estimateBuildSize(workDir, modelfile)
74+
if err := diskspace.Check(b.storageDir, totalSize); err != nil {
75+
logrus.Warnf("build: %v", err)
76+
}
77+
}
78+
7079
// using the local output by default.
7180
outputType := build.OutputTypeLocal
7281
if cfg.OutputRemote {
@@ -263,3 +272,39 @@ func getSourceInfo(workspace string, buildConfig *config.Build) (*source.Info, e
263272

264273
return info, nil
265274
}
275+
276+
// estimateBuildSize estimates the total size of files that will be built by summing
277+
// the sizes of all files referenced in the modelfile.
278+
func estimateBuildSize(workDir string, mf modelfile.Modelfile) int64 {
279+
var totalSize int64
280+
281+
files := []string{}
282+
files = append(files, mf.GetConfigs()...)
283+
files = append(files, mf.GetModels()...)
284+
files = append(files, mf.GetCodes()...)
285+
files = append(files, mf.GetDocs()...)
286+
287+
for _, file := range files {
288+
path := filepath.Join(workDir, file)
289+
info, err := os.Stat(path)
290+
if err != nil {
291+
logrus.Debugf("build: failed to stat file %s for size estimation: %v", path, err)
292+
continue
293+
}
294+
if info.IsDir() {
295+
_ = filepath.Walk(path, func(_ string, fi os.FileInfo, err error) error {
296+
if err != nil {
297+
return nil
298+
}
299+
if !fi.IsDir() {
300+
totalSize += fi.Size()
301+
}
302+
return nil
303+
})
304+
} else {
305+
totalSize += info.Size()
306+
}
307+
}
308+
309+
return totalSize
310+
}

pkg/backend/pull.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/modelpack/modctl/pkg/backend/remote"
3434
"github.com/modelpack/modctl/pkg/codec"
3535
"github.com/modelpack/modctl/pkg/config"
36+
"github.com/modelpack/modctl/pkg/diskspace"
3637
"github.com/modelpack/modctl/pkg/storage"
3738
)
3839

@@ -72,6 +73,21 @@ func (b *backend) Pull(ctx context.Context, target string, cfg *config.Pull) err
7273

7374
logrus.Debugf("pull: loaded manifest for target %s [manifest: %+v]", target, manifest)
7475

76+
// Check disk space before pulling layers.
77+
var totalSize int64
78+
for _, layer := range manifest.Layers {
79+
totalSize += layer.Size
80+
}
81+
totalSize += manifest.Config.Size
82+
83+
targetDir := b.storageDir
84+
if cfg.ExtractFromRemote && cfg.ExtractDir != "" {
85+
targetDir = cfg.ExtractDir
86+
}
87+
if err := diskspace.Check(targetDir, totalSize); err != nil {
88+
logrus.Warnf("pull: %v", err)
89+
}
90+
7591
// TODO: need refactor as currently use a global flag to control the progress bar render.
7692
if cfg.DisableProgress {
7793
internalpb.SetDisableProgress(true)

pkg/diskspace/check.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2024 The CNAI Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package diskspace
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
24+
"golang.org/x/sys/unix"
25+
)
26+
27+
const (
28+
// safetyMargin is the extra space ratio to account for metadata overhead
29+
// (manifests, temporary files, etc.). 10% extra required.
30+
safetyMargin = 1.1
31+
)
32+
33+
// Check checks if the directory has enough disk space for the required bytes.
34+
// It returns a descriptive error if space is insufficient, or nil if space is enough.
35+
// The caller should use the returned error for warning purposes only and not
36+
// treat it as a fatal error.
37+
func Check(dir string, requiredBytes int64) error {
38+
if requiredBytes <= 0 {
39+
return nil
40+
}
41+
42+
// Ensure the directory exists for statfs; walk up to find an existing parent.
43+
checkDir := dir
44+
for {
45+
if _, err := os.Stat(checkDir); err == nil {
46+
break
47+
}
48+
parent := filepath.Dir(checkDir)
49+
if parent == checkDir {
50+
// Reached filesystem root without finding an existing directory.
51+
return fmt.Errorf("cannot determine disk space: no existing directory found for path %s", dir)
52+
}
53+
checkDir = parent
54+
}
55+
56+
var stat unix.Statfs_t
57+
if err := unix.Statfs(checkDir, &stat); err != nil {
58+
return fmt.Errorf("failed to check disk space for %s: %w", dir, err)
59+
}
60+
61+
// Available space for non-root users.
62+
availableBytes := int64(stat.Bavail) * int64(stat.Bsize)
63+
requiredWithMargin := int64(float64(requiredBytes) * safetyMargin)
64+
65+
if availableBytes < requiredWithMargin {
66+
return fmt.Errorf(
67+
"insufficient disk space in %s: available %s, required %s (with 10%% safety margin)",
68+
dir, formatBytes(availableBytes), formatBytes(requiredWithMargin),
69+
)
70+
}
71+
72+
return nil
73+
}
74+
75+
// formatBytes formats bytes into a human-readable string.
76+
func formatBytes(bytes int64) string {
77+
const (
78+
kb = 1024
79+
mb = kb * 1024
80+
gb = mb * 1024
81+
tb = gb * 1024
82+
)
83+
84+
switch {
85+
case bytes >= tb:
86+
return fmt.Sprintf("%.2f TB", float64(bytes)/float64(tb))
87+
case bytes >= gb:
88+
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(gb))
89+
case bytes >= mb:
90+
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(mb))
91+
case bytes >= kb:
92+
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(kb))
93+
default:
94+
return fmt.Sprintf("%d B", bytes)
95+
}
96+
}

pkg/diskspace/check_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2024 The CNAI Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package diskspace
18+
19+
import (
20+
"strings"
21+
"testing"
22+
)
23+
24+
func TestCheck_ZeroBytes(t *testing.T) {
25+
err := Check("/tmp", 0)
26+
if err != nil {
27+
t.Errorf("expected nil error for zero bytes, got: %v", err)
28+
}
29+
}
30+
31+
func TestCheck_NegativeBytes(t *testing.T) {
32+
err := Check("/tmp", -1)
33+
if err != nil {
34+
t.Errorf("expected nil error for negative bytes, got: %v", err)
35+
}
36+
}
37+
38+
func TestCheck_SmallSize(t *testing.T) {
39+
// 1 byte should always have enough space
40+
err := Check("/tmp", 1)
41+
if err != nil {
42+
t.Errorf("expected nil error for 1 byte, got: %v", err)
43+
}
44+
}
45+
46+
func TestCheck_ExtremelyLargeSize(t *testing.T) {
47+
// 1 exabyte should always fail
48+
err := Check("/tmp", 1<<60)
49+
if err == nil {
50+
t.Error("expected error for extremely large size, got nil")
51+
}
52+
if !strings.Contains(err.Error(), "insufficient disk space") {
53+
t.Errorf("expected 'insufficient disk space' in error, got: %v", err)
54+
}
55+
}
56+
57+
func TestCheck_NonExistentDirWalksUp(t *testing.T) {
58+
// Should walk up to find an existing parent directory
59+
err := Check("/tmp/nonexistent-modctl-test-dir-12345/subdir", 1)
60+
if err != nil {
61+
t.Errorf("expected nil error when parent exists, got: %v", err)
62+
}
63+
}
64+
65+
func TestFormatBytes(t *testing.T) {
66+
tests := []struct {
67+
bytes int64
68+
expected string
69+
}{
70+
{0, "0 B"},
71+
{500, "500 B"},
72+
{1024, "1.00 KB"},
73+
{1536, "1.50 KB"},
74+
{1048576, "1.00 MB"},
75+
{1073741824, "1.00 GB"},
76+
{1099511627776, "1.00 TB"},
77+
}
78+
79+
for _, tt := range tests {
80+
result := formatBytes(tt.bytes)
81+
if result != tt.expected {
82+
t.Errorf("formatBytes(%d) = %s, want %s", tt.bytes, result, tt.expected)
83+
}
84+
}
85+
}

0 commit comments

Comments
 (0)