Skip to content

Commit 190a713

Browse files
authored
Merge pull request #4324 from apostasie/2025-06-fs-3
internal/filesystem consolidation, part 3 (no rename WriteFile, disaster recovery and rollbacks)
2 parents 36f9236 + 84894d9 commit 190a713

File tree

7 files changed

+638
-4
lines changed

7 files changed

+638
-4
lines changed

cmd/nerdctl/helpers/flagutil.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
"github.com/spf13/cobra"
2323

24+
"github.com/containerd/nerdctl/v2/pkg"
2425
"github.com/containerd/nerdctl/v2/pkg/api/types"
2526
)
2627

@@ -145,6 +146,12 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error)
145146
return types.GlobalCommandOptions{}, err
146147
}
147148

149+
// Point to dataRoot for filesystem-helpers implementing rollback / backups.
150+
err = pkg.InitFS(dataRoot)
151+
if err != nil {
152+
return types.GlobalCommandOptions{}, err
153+
}
154+
148155
return types.GlobalCommandOptions{
149156
Debug: debug,
150157
DebugFull: debugFull,

pkg/fs.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
Copyright The containerd 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 pkg
18+
19+
import "github.com/containerd/nerdctl/v2/pkg/internal/filesystem"
20+
21+
// InitFS will set the root location to store `internal/filesystem` ops files.
22+
// These files are used to allow `WriteFile` to backup and rollback content.
23+
// While they are transient in nature, they should still persist OS crashes / reboots, so, preferably under something
24+
// like XDGData, rather than tmp.
25+
func InitFS(path string) error {
26+
return filesystem.SetFilesystemOpsDirectory(path)
27+
}

pkg/internal/filesystem/consts.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,35 @@
1616

1717
package filesystem
1818

19-
import "io"
19+
import (
20+
"io"
21+
"os"
22+
"path/filepath"
23+
)
2024

2125
const (
26+
// Max size of path components
2227
pathComponentMaxLength = 255
23-
privateFilePermission = 0o600
28+
privateFilePermission = os.FileMode(0o600)
29+
privateDirPermission = os.FileMode(0o700)
2430
)
2531

2632
var (
2733
// Lightweight indirection to ease testing
2834
ioCopy = io.Copy
35+
36+
// Location (under XDG data home) used for markers and backups
37+
filesystemOpsPath = "filesystem-ops"
38+
// Suffix for markers and backup files
39+
markerSuffix = "in-progress"
40+
backupSuffix = "backup"
41+
42+
// holdLocation points to where markers and backup files will be held. This should NOT be let to /tmp,
43+
// but instead be explicitly configured with SetFilesystemOpsDirectory.
44+
holdLocation = os.TempDir()
2945
)
46+
47+
func SetFilesystemOpsDirectory(path string) error {
48+
holdLocation = filepath.Join(path, filesystemOpsPath)
49+
return os.MkdirAll(holdLocation, privateDirPermission)
50+
}

pkg/internal/filesystem/helpers.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/*
2+
Copyright The containerd 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 filesystem
18+
19+
import (
20+
"crypto/sha256"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"os"
25+
"path/filepath"
26+
"time"
27+
)
28+
29+
const (
30+
removeMarker = "remove"
31+
)
32+
33+
func ensureRecovery(filename string) (err error) {
34+
// Check for a marker file.
35+
// No marker means all fine, nothing to be done.
36+
// Any other error is a hard error.
37+
var op string
38+
if op, err = markerRead(filename); err != nil {
39+
if os.IsNotExist(err) {
40+
err = nil
41+
}
42+
return err
43+
}
44+
45+
// We have a marker. We know we were interrupted.
46+
// Check for a possible backup file.
47+
var exists bool
48+
if exists, err = backupExists(filename); err != nil {
49+
return err
50+
}
51+
52+
// If we have a backup, restore from it
53+
if exists {
54+
if err = backupRestore(filename); err != nil {
55+
return err
56+
}
57+
} else {
58+
// We do not see a backup.
59+
// Do we have a final destination then?
60+
_, err = os.Stat(filename)
61+
// Any error but does not exist is a hard error.
62+
if err != nil && !os.IsNotExist(err) {
63+
return err
64+
}
65+
66+
// If we do NOT have a destination, nothing to be done - we already took care of it, though we were interrupted
67+
// mid-recovery.
68+
69+
// If we DO have a destination:
70+
if err == nil {
71+
// Either:
72+
// - there was no original, so we need to remove it (marker contains `remove`)
73+
// - or we were interrupted ALSO during the recovery attempt, after the backup restore above and before deleting the marker
74+
// in which case we do NOT want to remove as the file has already been restored.
75+
if op == removeMarker {
76+
// Errors on remove are hard errors.
77+
if err = os.Remove(filename); err != nil {
78+
return err
79+
}
80+
}
81+
}
82+
}
83+
84+
// Ok, we successfully recovered, now, remove the marker and return
85+
return markerRemove(filename)
86+
}
87+
88+
// backupSave does perform a backup of the provided file at `path`.
89+
func backupSave(path string) error {
90+
return internalCopy(path, backupLocation(path))
91+
}
92+
93+
// backupRestore restores a file from its backup.
94+
// On success the backup is deleted.
95+
func backupRestore(path string) error {
96+
err := internalCopy(backupLocation(path), path)
97+
if err == nil {
98+
err = os.Remove(backupLocation(path))
99+
}
100+
101+
return err
102+
}
103+
104+
// backupExists checks if a backup file exists for file located at `path`.
105+
func backupExists(path string) (bool, error) {
106+
_, err := os.Stat(backupLocation(path))
107+
if os.IsNotExist(err) {
108+
return false, nil
109+
}
110+
111+
return err == nil, err
112+
}
113+
114+
// backupLocation returns the location of the backup for path.
115+
func backupLocation(path string) string {
116+
return location(path) + backupSuffix
117+
}
118+
119+
// markerCreate saves a marker file with the current time.
120+
// Markers are used to indicate an operation is in progress and allow for disaster recovery.
121+
func markerCreate(path string, op string) (err error) {
122+
var marker *os.File
123+
marker, err = os.OpenFile(markerLocation(path), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, privateFilePermission)
124+
if err != nil {
125+
return err
126+
}
127+
128+
defer func() {
129+
// If we errored on sync or close, remove the marker (ignore removal errors)
130+
if err = errors.Join(err, marker.Close()); err != nil {
131+
_ = markerRemove(path)
132+
}
133+
}()
134+
135+
_, err = marker.Write([]byte(op))
136+
if err != nil {
137+
return err
138+
}
139+
140+
return marker.Sync()
141+
}
142+
143+
// markerRead reads the content of a marker file if it exists (contains the time at which it was created).
144+
func markerRead(path string) (string, error) {
145+
data, err := os.ReadFile(markerLocation(path))
146+
if err != nil {
147+
return "", err
148+
}
149+
150+
return string(data), nil
151+
}
152+
153+
// markerRemove deletes a marker file.
154+
func markerRemove(path string) error {
155+
return os.Remove(markerLocation(path))
156+
}
157+
158+
// markerLocation returns the location of the marker file for a given path.
159+
func markerLocation(path string) string {
160+
return location(path) + markerSuffix
161+
}
162+
163+
// location returns the filesystem-ops path associated with a given file (where marker and backups are located).
164+
// The location is unique (see hash), and shows the first 16 characters of the filename for readability.
165+
func location(path string) string {
166+
dir := filepath.Dir(path)
167+
base := filepath.Base(path)
168+
pretty := base
169+
// Ensure that we do not blow up filesystem length limits
170+
if len(pretty) > 16 {
171+
pretty = pretty[:16]
172+
}
173+
return filepath.Join(holdLocation, hash(dir)+"-"+pretty+"-"+hash(base)+"-")
174+
}
175+
176+
// hash does return the first 8 characters of the shasum256 of the provided string.
177+
// Chances of collision are 50% with 77,000 *simultaneous* entries.
178+
func hash(s string) string {
179+
return fmt.Sprintf("%x", sha256.Sum256([]byte(s)))[0:8]
180+
}
181+
182+
// internalCopy performs a simple copy from source to destination.
183+
// This in itself is not safe.
184+
func internalCopy(sourcePath, destinationPath string) (err error) {
185+
var source *os.File
186+
187+
// Open source
188+
source, err = os.OpenFile(sourcePath, os.O_RDONLY, privateFilePermission)
189+
if err != nil {
190+
return err
191+
}
192+
193+
// Read file length
194+
srcInfo, err := source.Stat()
195+
if err != nil {
196+
return err
197+
}
198+
199+
defer func() {
200+
err = errors.Join(err, source.Close())
201+
}()
202+
203+
return fileWrite(source, srcInfo.Size(), destinationPath, privateFilePermission, srcInfo.ModTime())
204+
}
205+
206+
// fileWrite performs a simple write to the destination file from the provided io.Reader.
207+
// This in itself is not safe.
208+
func fileWrite(source io.Reader, size int64, destinationPath string, perm os.FileMode, mTime time.Time) (err error) {
209+
var destination *os.File
210+
mustClose := true
211+
212+
// Open destination
213+
destination, err = os.OpenFile(destinationPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
214+
if err != nil {
215+
return err
216+
}
217+
218+
defer func() {
219+
// Close if need be.
220+
if mustClose {
221+
err = errors.Join(err, destination.Close())
222+
}
223+
224+
// Remove destination if we failed anywhere. Ignore removal failures.
225+
if err != nil {
226+
_ = os.Remove(destinationPath)
227+
}
228+
}()
229+
230+
// Copy over
231+
var n int64
232+
n, err = ioCopy(destination, source)
233+
if err != nil {
234+
return err
235+
}
236+
237+
if n < size {
238+
return io.ErrShortWrite
239+
}
240+
241+
// Ensure data is committed
242+
if err = destination.Sync(); err != nil {
243+
return err
244+
}
245+
246+
err = destination.Close()
247+
mustClose = false
248+
if err != nil {
249+
return err
250+
}
251+
252+
if !mTime.IsZero() {
253+
err = os.Chtimes(destinationPath, mTime, mTime)
254+
}
255+
256+
return err
257+
}

pkg/internal/filesystem/os.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,35 @@
1616

1717
package filesystem
1818

19-
import "os"
19+
import (
20+
"errors"
21+
"os"
22+
)
2023

24+
func ReadFile(filename string) (data []byte, err error) {
25+
if err = ensureRecovery(filename); err != nil {
26+
return nil, err
27+
}
28+
29+
data, err = os.ReadFile(filename)
30+
if err != nil {
31+
return nil, errors.Join(ErrFilesystemFailure, err)
32+
}
33+
34+
return data, nil
35+
}
36+
37+
func Stat(filename string) (os.FileInfo, error) {
38+
if err := ensureRecovery(filename); err != nil {
39+
return nil, errors.Join(ErrFilesystemFailure, err)
40+
}
41+
42+
return os.Stat(filename)
43+
}
44+
45+
// WriteFile implements an atomic and durable alternative to os.WriteFile that does not change inodes (unlike the usual
46+
// approach on atomic writes that relies on renaming files).
2147
func WriteFile(filename string, data []byte, perm os.FileMode) error {
22-
return WriteFileWithRename(filename, data, perm)
48+
_, err := WriteFileWithRollback(filename, data, perm)
49+
return err
2350
}

0 commit comments

Comments
 (0)