66 "os"
77 "path/filepath"
88 "strings"
9+ "time"
910
1011 "github.com/docker/model-runner/pkg/distribution/internal/progress"
1112
@@ -78,6 +79,12 @@ type blob interface {
7879 Uncompressed () (io.ReadCloser , error )
7980}
8081
82+ // layerWithDigest extends blob to include the Digest method
83+ type layerWithDigest interface {
84+ blob
85+ Digest () (v1.Hash , error )
86+ }
87+
8188// writeLayer writes the layer blob to the store.
8289// It returns true when a new blob was created and the blob's DiffID.
8390func (s * LocalStore ) writeLayer (layer blob , updates chan <- v1.Update ) (bool , v1.Hash , error ) {
@@ -94,13 +101,28 @@ func (s *LocalStore) writeLayer(layer blob, updates chan<- v1.Update) (bool, v1.
94101 return false , hash , nil
95102 }
96103
104+ // Check if we're resuming an incomplete download
105+ incompleteSize , err := s .GetIncompleteSize (hash )
106+ if err != nil {
107+ return false , v1.Hash {}, fmt .Errorf ("check incomplete size: %w" , err )
108+ }
109+
97110 lr , err := layer .Uncompressed ()
98111 if err != nil {
99112 return false , v1.Hash {}, fmt .Errorf ("get blob contents: %w" , err )
100113 }
101114 defer lr .Close ()
102- r := progress .NewReader (lr , updates )
103115
116+ // Wrap the reader with progress reporting, accounting for already downloaded bytes
117+ var r io.Reader
118+ if incompleteSize > 0 {
119+ r = progress .NewReaderWithOffset (lr , updates , incompleteSize )
120+ } else {
121+ r = progress .NewReader (lr , updates )
122+ }
123+
124+ // WriteBlob will handle appending to incomplete files
125+ // The HTTP layer will handle resuming via Range headers
104126 if err := s .WriteBlob (hash , r ); err != nil {
105127 return false , hash , err
106128 }
@@ -109,6 +131,7 @@ func (s *LocalStore) writeLayer(layer blob, updates chan<- v1.Update) (bool, v1.
109131
110132// WriteBlob writes the blob to the store, reporting progress to the given channel.
111133// If the blob is already in the store, it is a no-op and the blob is not consumed from the reader.
134+ // If an incomplete download exists, it will be resumed by appending to the existing file.
112135func (s * LocalStore ) WriteBlob (diffID v1.Hash , r io.Reader ) error {
113136 hasBlob , err := s .hasBlob (diffID )
114137 if err != nil {
@@ -122,21 +145,83 @@ func (s *LocalStore) WriteBlob(diffID v1.Hash, r io.Reader) error {
122145 if err != nil {
123146 return fmt .Errorf ("get blob path: %w" , err )
124147 }
125- f , err := createFile (incompletePath (path ))
126- if err != nil {
127- return fmt .Errorf ("create blob file: %w" , err )
148+
149+ incompletePath := incompletePath (path )
150+
151+ // Check if we're resuming a partial download
152+ var f * os.File
153+ var isResume bool
154+ if _ , err := os .Stat (incompletePath ); err == nil {
155+ // Before resuming, verify that the incomplete file isn't already complete
156+ existingFile , err := os .Open (incompletePath )
157+ if err != nil {
158+ return fmt .Errorf ("open incomplete file for verification: %w" , err )
159+ }
160+
161+ computedHash , _ , err := v1 .SHA256 (existingFile )
162+ existingFile .Close ()
163+
164+ if err == nil && computedHash .String () == diffID .String () {
165+ // File is already complete, just rename it
166+ if err := os .Rename (incompletePath , path ); err != nil {
167+ return fmt .Errorf ("rename completed blob file: %w" , err )
168+ }
169+ return nil
170+ }
171+
172+ // File is incomplete or corrupt, try to resume
173+ isResume = true
174+ f , err = os .OpenFile (incompletePath , os .O_WRONLY | os .O_APPEND , 0644 )
175+ if err != nil {
176+ return fmt .Errorf ("open incomplete blob file for resume: %w" , err )
177+ }
178+ } else {
179+ // New download: create file
180+ f , err = createFile (incompletePath )
181+ if err != nil {
182+ return fmt .Errorf ("create blob file: %w" , err )
183+ }
128184 }
129- defer os .Remove (incompletePath (path ))
130185 defer f .Close ()
131186
132187 if _ , err := io .Copy (f , r ); err != nil {
188+ // If we were resuming and copy failed, the incomplete file might be corrupt
189+ if isResume {
190+ _ = os .Remove (incompletePath )
191+ }
133192 return fmt .Errorf ("copy blob %q to store: %w" , diffID .String (), err )
134193 }
135194
136195 f .Close () // Rename will fail on Windows if the file is still open.
137- if err := os .Rename (incompletePath (path ), path ); err != nil {
196+
197+ // For resumed downloads, verify the complete file's hash before finalizing
198+ // (For new downloads, the stream was already verified during download)
199+ if isResume {
200+ completeFile , err := os .Open (incompletePath )
201+ if err != nil {
202+ return fmt .Errorf ("open completed file for verification: %w" , err )
203+ }
204+ defer completeFile .Close ()
205+
206+ computedHash , _ , err := v1 .SHA256 (completeFile )
207+ if err != nil {
208+ return fmt .Errorf ("compute hash of completed file: %w" , err )
209+ }
210+
211+ if computedHash .String () != diffID .String () {
212+ // The resumed download is corrupt, remove it so we can start fresh next time
213+ _ = os .Remove (incompletePath )
214+ return fmt .Errorf ("hash mismatch after download: got %s, want %s" , computedHash , diffID )
215+ }
216+ }
217+
218+ if err := os .Rename (incompletePath , path ); err != nil {
138219 return fmt .Errorf ("rename blob file: %w" , err )
139220 }
221+
222+ // Only remove incomplete file if rename succeeded (though rename should have moved it)
223+ // This is a safety cleanup in case rename didn't remove the source
224+ os .Remove (incompletePath )
140225 return nil
141226}
142227
@@ -160,6 +245,25 @@ func (s *LocalStore) hasBlob(hash v1.Hash) (bool, error) {
160245 return false , nil
161246}
162247
248+ // GetIncompleteSize returns the size of an incomplete blob if it exists, or 0 if it doesn't.
249+ func (s * LocalStore ) GetIncompleteSize (hash v1.Hash ) (int64 , error ) {
250+ path , err := s .blobPath (hash )
251+ if err != nil {
252+ return 0 , fmt .Errorf ("get blob path: %w" , err )
253+ }
254+
255+ incompletePath := incompletePath (path )
256+ stat , err := os .Stat (incompletePath )
257+ if err != nil {
258+ if os .IsNotExist (err ) {
259+ return 0 , nil
260+ }
261+ return 0 , fmt .Errorf ("stat incomplete file: %w" , err )
262+ }
263+
264+ return stat .Size (), nil
265+ }
266+
163267// createFile is a wrapper around os.Create that creates any parent directories as needed.
164268func createFile (path string ) (* os.File , error ) {
165269 if err := os .MkdirAll (filepath .Dir (path ), 0777 ); err != nil {
@@ -201,3 +305,59 @@ func (s *LocalStore) writeConfigFile(mdl v1.Image) (bool, error) {
201305 }
202306 return true , nil
203307}
308+
309+ // CleanupStaleIncompleteFiles removes incomplete download files that haven't been modified
310+ // for more than the specified duration. This prevents disk space leaks from abandoned downloads.
311+ func (s * LocalStore ) CleanupStaleIncompleteFiles (maxAge time.Duration ) error {
312+ blobsPath := s .blobsDir ()
313+ if _ , err := os .Stat (blobsPath ); os .IsNotExist (err ) {
314+ // Blobs directory doesn't exist yet, nothing to clean up
315+ return nil
316+ }
317+
318+ var cleanedCount int
319+ var cleanupErrors []error
320+
321+ // Walk through the blobs directory looking for .incomplete files
322+ err := filepath .Walk (blobsPath , func (path string , info os.FileInfo , err error ) error {
323+ if err != nil {
324+ // Continue walking even if we encounter errors on individual files
325+ return nil
326+ }
327+
328+ // Skip directories
329+ if info .IsDir () {
330+ return nil
331+ }
332+
333+ // Only process .incomplete files
334+ if ! strings .HasSuffix (path , ".incomplete" ) {
335+ return nil
336+ }
337+
338+ // Check if file is older than maxAge
339+ if time .Since (info .ModTime ()) > maxAge {
340+ if removeErr := os .Remove (path ); removeErr != nil {
341+ cleanupErrors = append (cleanupErrors , fmt .Errorf ("failed to remove stale incomplete file %s: %w" , path , removeErr ))
342+ } else {
343+ cleanedCount ++
344+ }
345+ }
346+
347+ return nil
348+ })
349+
350+ if err != nil {
351+ return fmt .Errorf ("walking blobs directory: %w" , err )
352+ }
353+
354+ if len (cleanupErrors ) > 0 {
355+ return fmt .Errorf ("encountered %d errors during cleanup (cleaned %d files): %v" , len (cleanupErrors ), cleanedCount , cleanupErrors [0 ])
356+ }
357+
358+ if cleanedCount > 0 {
359+ fmt .Printf ("Cleaned up %d stale incomplete download file(s)\n " , cleanedCount )
360+ }
361+
362+ return nil
363+ }
0 commit comments