Skip to content

Commit 6e2523c

Browse files
authored
Merge pull request #185 from thaJeztah/integrate_atomicwriter
integrate pkg/atomicwriter
2 parents 71f0c5e + 17fe011 commit 6e2523c

File tree

6 files changed

+593
-5
lines changed

6 files changed

+593
-5
lines changed

Diff for: .github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
run: |
2727
# This corresponds with the list in Makefile:1, but omits the "userns"
2828
# and "capability" modules, which require go1.21 as minimum.
29-
echo 'PACKAGES=mountinfo mount reexec sequential signal symlink user' >> $GITHUB_ENV
29+
echo 'PACKAGES=atomicwriter mountinfo mount reexec sequential signal symlink user' >> $GITHUB_ENV
3030
- name: go mod tidy
3131
run: |
3232
make foreach CMD="go mod tidy"

Diff for: Makefile

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
PACKAGES ?= capability mountinfo mount reexec sequential signal symlink user userns # IMPORTANT: when updating this list, also update the conditional one in .github/workflows/test.yml
1+
PACKAGES ?= atomicwriter capability mountinfo mount reexec sequential signal symlink user userns # IMPORTANT: when updating this list, also update the conditional one in .github/workflows/test.yml
22
BINDIR ?= _build/bin
33
CROSS ?= linux/arm linux/arm64 linux/ppc64le linux/s390x \
44
freebsd/amd64 openbsd/amd64 darwin/amd64 darwin/arm64 windows/amd64
@@ -29,16 +29,23 @@ test: test-local
2929
test: CMD=go test $(RUN_VIA_SUDO) -v -coverprofile=coverage.txt -covermode=atomic .
3030
test: foreach
3131

32-
# Test the mount module against the local mountinfo source code instead of the
33-
# release specified in its go.mod. This allows catching regressions / breaking
34-
# changes in mountinfo.
32+
# Some modules in this repo have interdependencies:
33+
# - mount depends on mountinfo
34+
# - atomicwrite depends on sequential
35+
#
36+
# The code below tests these modules against their local dependencies
37+
# to catch regressions / breaking changes early.
3538
.PHONY: test-local
3639
test-local: MOD = -modfile=go-local.mod
3740
test-local:
3841
echo 'replace github.com/moby/sys/mountinfo => ../mountinfo' | cat mount/go.mod - > mount/go-local.mod
3942
# Run go mod tidy to make sure mountinfo dependency versions are met.
4043
cd mount && go mod tidy $(MOD) && go test $(MOD) $(RUN_VIA_SUDO) -v .
4144
$(RM) mount/go-local.*
45+
echo 'replace github.com/moby/sys/sequential => ../sequential' | cat atomicwriter/go.mod - > atomicwriter/go-local.mod
46+
# Run go mod tidy to make sure sequential dependency versions are met.
47+
cd atomicwriter && go mod tidy $(MOD) && go test $(MOD) $(RUN_VIA_SUDO) -v .
48+
$(RM) atomicwriter/go-local.*
4249

4350
.PHONY: lint
4451
lint: $(BINDIR)/golangci-lint

Diff for: atomicwriter/atomicwriter.go

+245
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Package atomicwriter provides utilities to perform atomic writes to a
2+
// file or set of files.
3+
package atomicwriter
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"syscall"
12+
13+
"github.com/moby/sys/sequential"
14+
)
15+
16+
func validateDestination(fileName string) error {
17+
if fileName == "" {
18+
return errors.New("file name is empty")
19+
}
20+
if dir := filepath.Dir(fileName); dir != "" && dir != "." && dir != ".." {
21+
di, err := os.Stat(dir)
22+
if err != nil {
23+
return fmt.Errorf("invalid output path: %w", err)
24+
}
25+
if !di.IsDir() {
26+
return fmt.Errorf("invalid output path: %w", &os.PathError{Op: "stat", Path: dir, Err: syscall.ENOTDIR})
27+
}
28+
}
29+
30+
// Deliberately using Lstat here to match the behavior of [os.Rename],
31+
// which is used when completing the write and does not resolve symlinks.
32+
fi, err := os.Lstat(fileName)
33+
if err != nil {
34+
if os.IsNotExist(err) {
35+
return nil
36+
}
37+
return fmt.Errorf("failed to stat output path: %w", err)
38+
}
39+
40+
switch mode := fi.Mode(); {
41+
case mode.IsRegular():
42+
return nil // Regular file
43+
case mode&os.ModeDir != 0:
44+
return errors.New("cannot write to a directory")
45+
case mode&os.ModeSymlink != 0:
46+
return errors.New("cannot write to a symbolic link directly")
47+
case mode&os.ModeNamedPipe != 0:
48+
return errors.New("cannot write to a named pipe (FIFO)")
49+
case mode&os.ModeSocket != 0:
50+
return errors.New("cannot write to a socket")
51+
case mode&os.ModeDevice != 0:
52+
if mode&os.ModeCharDevice != 0 {
53+
return errors.New("cannot write to a character device file")
54+
}
55+
return errors.New("cannot write to a block device file")
56+
case mode&os.ModeSetuid != 0:
57+
return errors.New("cannot write to a setuid file")
58+
case mode&os.ModeSetgid != 0:
59+
return errors.New("cannot write to a setgid file")
60+
case mode&os.ModeSticky != 0:
61+
return errors.New("cannot write to a sticky bit file")
62+
default:
63+
return fmt.Errorf("unknown file mode: %[1]s (%#[1]o)", mode)
64+
}
65+
}
66+
67+
// New returns a WriteCloser so that writing to it writes to a
68+
// temporary file and closing it atomically changes the temporary file to
69+
// destination path. Writing and closing concurrently is not allowed.
70+
// NOTE: umask is not considered for the file's permissions.
71+
//
72+
// New uses [sequential.CreateTemp] to use sequential file access on Windows,
73+
// avoiding depleting the standby list un-necessarily. On Linux, this equates to
74+
// a regular [os.CreateTemp]. Refer to the [Win32 API documentation] for details
75+
// on sequential file access.
76+
//
77+
// [Win32 API documentation]: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#FILE_FLAG_SEQUENTIAL_SCAN
78+
func New(filename string, perm os.FileMode) (io.WriteCloser, error) {
79+
if err := validateDestination(filename); err != nil {
80+
return nil, err
81+
}
82+
abspath, err := filepath.Abs(filename)
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
f, err := sequential.CreateTemp(filepath.Dir(abspath), ".tmp-"+filepath.Base(filename))
88+
if err != nil {
89+
return nil, err
90+
}
91+
return &atomicFileWriter{
92+
f: f,
93+
fn: abspath,
94+
perm: perm,
95+
}, nil
96+
}
97+
98+
// WriteFile atomically writes data to a file named by filename and with the
99+
// specified permission bits. The given filename is created if it does not exist,
100+
// but the destination directory must exist. It can be used as a drop-in replacement
101+
// for [os.WriteFile], but currently does not allow the destination path to be
102+
// a symlink. WriteFile is implemented using [New] for its implementation.
103+
//
104+
// NOTE: umask is not considered for the file's permissions.
105+
func WriteFile(filename string, data []byte, perm os.FileMode) error {
106+
f, err := New(filename, perm)
107+
if err != nil {
108+
return err
109+
}
110+
n, err := f.Write(data)
111+
if err == nil && n < len(data) {
112+
err = io.ErrShortWrite
113+
f.(*atomicFileWriter).writeErr = err
114+
}
115+
if err1 := f.Close(); err == nil {
116+
err = err1
117+
}
118+
return err
119+
}
120+
121+
type atomicFileWriter struct {
122+
f *os.File
123+
fn string
124+
writeErr error
125+
written bool
126+
perm os.FileMode
127+
}
128+
129+
func (w *atomicFileWriter) Write(dt []byte) (int, error) {
130+
w.written = true
131+
n, err := w.f.Write(dt)
132+
if err != nil {
133+
w.writeErr = err
134+
}
135+
return n, err
136+
}
137+
138+
func (w *atomicFileWriter) Close() (retErr error) {
139+
defer func() {
140+
if err := os.Remove(w.f.Name()); !errors.Is(err, os.ErrNotExist) && retErr == nil {
141+
retErr = err
142+
}
143+
}()
144+
if err := w.f.Sync(); err != nil {
145+
_ = w.f.Close()
146+
return err
147+
}
148+
if err := w.f.Close(); err != nil {
149+
return err
150+
}
151+
if err := os.Chmod(w.f.Name(), w.perm); err != nil {
152+
return err
153+
}
154+
if w.writeErr == nil && w.written {
155+
return os.Rename(w.f.Name(), w.fn)
156+
}
157+
return nil
158+
}
159+
160+
// WriteSet is used to atomically write a set
161+
// of files and ensure they are visible at the same time.
162+
// Must be committed to a new directory.
163+
type WriteSet struct {
164+
root string
165+
}
166+
167+
// NewWriteSet creates a new atomic write set to
168+
// atomically create a set of files. The given directory
169+
// is used as the base directory for storing files before
170+
// commit. If no temporary directory is given the system
171+
// default is used.
172+
func NewWriteSet(tmpDir string) (*WriteSet, error) {
173+
td, err := os.MkdirTemp(tmpDir, "write-set-")
174+
if err != nil {
175+
return nil, err
176+
}
177+
178+
return &WriteSet{
179+
root: td,
180+
}, nil
181+
}
182+
183+
// WriteFile writes a file to the set, guaranteeing the file
184+
// has been synced.
185+
func (ws *WriteSet) WriteFile(filename string, data []byte, perm os.FileMode) error {
186+
f, err := ws.FileWriter(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
187+
if err != nil {
188+
return err
189+
}
190+
n, err := f.Write(data)
191+
if err == nil && n < len(data) {
192+
err = io.ErrShortWrite
193+
}
194+
if err1 := f.Close(); err == nil {
195+
err = err1
196+
}
197+
return err
198+
}
199+
200+
type syncFileCloser struct {
201+
*os.File
202+
}
203+
204+
func (w syncFileCloser) Close() error {
205+
err := w.File.Sync()
206+
if err1 := w.File.Close(); err == nil {
207+
err = err1
208+
}
209+
return err
210+
}
211+
212+
// FileWriter opens a file writer inside the set. The file
213+
// should be synced and closed before calling commit.
214+
//
215+
// FileWriter uses [sequential.OpenFile] to use sequential file access on Windows,
216+
// avoiding depleting the standby list un-necessarily. On Linux, this equates to
217+
// a regular [os.OpenFile]. Refer to the [Win32 API documentation] for details
218+
// on sequential file access.
219+
//
220+
// [Win32 API documentation]: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#FILE_FLAG_SEQUENTIAL_SCAN
221+
func (ws *WriteSet) FileWriter(name string, flag int, perm os.FileMode) (io.WriteCloser, error) {
222+
f, err := sequential.OpenFile(filepath.Join(ws.root, name), flag, perm)
223+
if err != nil {
224+
return nil, err
225+
}
226+
return syncFileCloser{f}, nil
227+
}
228+
229+
// Cancel cancels the set and removes all temporary data
230+
// created in the set.
231+
func (ws *WriteSet) Cancel() error {
232+
return os.RemoveAll(ws.root)
233+
}
234+
235+
// Commit moves all created files to the target directory. The
236+
// target directory must not exist and the parent of the target
237+
// directory must exist.
238+
func (ws *WriteSet) Commit(target string) error {
239+
return os.Rename(ws.root, target)
240+
}
241+
242+
// String returns the location the set is writing to.
243+
func (ws *WriteSet) String() string {
244+
return ws.root
245+
}

0 commit comments

Comments
 (0)