Support atomic directory promotion across platforms
This commit is contained in:
@@ -118,6 +118,12 @@ The directory is immutable once promoted. Configured lanes become
|
|||||||
the bundle and `index.json` are retained for audit and resume validation but
|
the bundle and `index.json` are retained for audit and resume validation but
|
||||||
are not selectable or published implicitly.
|
are not selectable or published implicitly.
|
||||||
|
|
||||||
|
Atomic Notarius bundle promotion is supported on Linux, macOS, and Windows.
|
||||||
|
On other operating systems, extraction fails before copying the bundle into a
|
||||||
|
temporary promotion tree because Narratio has no verified atomic no-replace
|
||||||
|
directory primitive there. This is an extraction limitation, not a broader
|
||||||
|
platform-support guarantee for every Narratio workflow.
|
||||||
|
|
||||||
Run-local diagnostics are:
|
Run-local diagnostics are:
|
||||||
|
|
||||||
- `runs/{run_id}/extract/notarius.receipt.json`
|
- `runs/{run_id}/extract/notarius.receipt.json`
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ implementation sequence.
|
|||||||
| Stage 10 | Complete |
|
| Stage 10 | Complete |
|
||||||
| Stage 11 | Complete |
|
| Stage 11 | Complete |
|
||||||
| Stage 12 | Complete |
|
| Stage 12 | Complete |
|
||||||
| Stage 13 | Pending |
|
| Stage 13 | Complete |
|
||||||
| Stage 14 | Pending |
|
| Stage 14 | Pending |
|
||||||
|
|
||||||
After completing and validating a stage, update only that stage's row to
|
After completing and validating a stage, update only that stage's row to
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -16,10 +15,17 @@ const (
|
|||||||
promotedFileMode = 0o644
|
promotedFileMode = 0o644
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrAtomicDirectoryPromotionUnsupported indicates that the current operating
|
||||||
|
// system lacks the atomic no-replace primitive required by PromoteDirectory.
|
||||||
|
var ErrAtomicDirectoryPromotionUnsupported = errors.New("atomic no-replace directory promotion is unsupported")
|
||||||
|
|
||||||
// PromoteDirectory copies an existing regular-file tree into a new directory
|
// PromoteDirectory copies an existing regular-file tree into a new directory
|
||||||
// and installs the complete copy atomically. It never removes the source or
|
// and installs the complete copy atomically. It never removes the source or
|
||||||
// replaces an existing destination.
|
// replaces an existing destination.
|
||||||
func PromoteDirectory(src, dst string) error {
|
func PromoteDirectory(src, dst string) error {
|
||||||
|
if err := checkAtomicDirectoryPromotionSupport(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return promoteDirectory(src, dst, renameDirectoryNoReplace)
|
return promoteDirectory(src, dst, renameDirectoryNoReplace)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +90,9 @@ func promoteDirectory(src, dst string, install func(string, string) error) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeTemporary = false
|
removeTemporary = false
|
||||||
_ = syncDirectory(destinationParent)
|
if err := syncDirectory(destinationParent); err != nil {
|
||||||
|
return fmt.Errorf("sync destination parent: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,20 +181,6 @@ func copyRegularFile(src, dst string, inspected os.FileInfo) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncDirectory(path string) error {
|
|
||||||
directory, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = directory.Close() }()
|
|
||||||
|
|
||||||
err = directory.Sync()
|
|
||||||
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathWithin(parent, candidate string) (bool, error) {
|
func pathWithin(parent, candidate string) (bool, error) {
|
||||||
absoluteParent, err := filepath.Abs(parent)
|
absoluteParent, err := filepath.Abs(parent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows
|
//go:build linux || darwin
|
||||||
|
|
||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build linux || darwin || windows
|
||||||
|
|
||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -32,6 +35,7 @@ func TestPromoteDirectoryCopiesNestedRegularTree(t *testing.T) {
|
|||||||
assertFileBytes(t, filepath.Join(dst, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
|
assertFileBytes(t, filepath.Join(dst, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
|
||||||
assertFileBytes(t, filepath.Join(dst, "z-last.txt"), []byte("last"))
|
assertFileBytes(t, filepath.Join(dst, "z-last.txt"), []byte("last"))
|
||||||
|
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} {
|
for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,6 +54,7 @@ func TestPromoteDirectoryCopiesNestedRegularTree(t *testing.T) {
|
|||||||
t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode)
|
t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assertFileBytes(t, filepath.Join(src, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
|
assertFileBytes(t, filepath.Join(src, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
|
||||||
assertNoMatchingTempDirectories(t, root, ".promoted.tmp-")
|
assertNoMatchingTempDirectories(t, root, ".promoted.tmp-")
|
||||||
|
|||||||
37
internal/fileops/directory_unsupported_test.go
Normal file
37
internal/fileops/directory_unsupported_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPromoteDirectoryFailsBeforeCreatingTemporaryTree(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "source")
|
||||||
|
dst := filepath.Join(root, "destination")
|
||||||
|
if err := os.Mkdir(src, 0o755); err != nil {
|
||||||
|
t.Fatalf("Mkdir(source) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "value.txt"), []byte("source"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(source) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := PromoteDirectory(src, dst)
|
||||||
|
if !errors.Is(err, ErrAtomicDirectoryPromotionUnsupported) {
|
||||||
|
t.Fatalf("PromoteDirectory() error = %v, want unsupported capability", err)
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(root)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("ReadDir(root) error = %v", readErr)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Name() == filepath.Base(dst) || strings.HasPrefix(entry.Name(), ".destination.tmp-") {
|
||||||
|
t.Fatalf("unsupported promotion created %q", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/fileops/rename_noreplace_darwin.go
Normal file
11
internal/fileops/rename_noreplace_darwin.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
func checkAtomicDirectoryPromotionSupport() error { return nil }
|
||||||
|
|
||||||
|
func renameDirectoryNoReplace(src, dst string) error {
|
||||||
|
return unix.RenamexNp(src, dst, unix.RENAME_EXCL)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ package fileops
|
|||||||
|
|
||||||
import "golang.org/x/sys/unix"
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
func checkAtomicDirectoryPromotionSupport() error { return nil }
|
||||||
|
|
||||||
func renameDirectoryNoReplace(src, dst string) error {
|
func renameDirectoryNoReplace(src, dst string) error {
|
||||||
return unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
|
return unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
//go:build !linux
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkAtomicDirectoryPromotionSupport() error {
|
||||||
|
return fmt.Errorf("%w on %s", ErrAtomicDirectoryPromotionUnsupported, runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
func renameDirectoryNoReplace(_, _ string) error {
|
func renameDirectoryNoReplace(_, _ string) error {
|
||||||
return fmt.Errorf("atomic no-replace directory rename is unsupported on this platform")
|
return checkAtomicDirectoryPromotionSupport()
|
||||||
}
|
}
|
||||||
|
|||||||
26
internal/fileops/rename_noreplace_supported_test.go
Normal file
26
internal/fileops/rename_noreplace_supported_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//go:build linux || darwin || windows
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenameDirectoryNoReplacePreservesExistingDestination(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "source")
|
||||||
|
dst := filepath.Join(root, "destination")
|
||||||
|
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("source"), 0o644)
|
||||||
|
mustWriteFile(t, filepath.Join(dst, "value.txt"), []byte("existing"), 0o644)
|
||||||
|
|
||||||
|
if err := renameDirectoryNoReplace(src, dst); err == nil {
|
||||||
|
t.Fatal("renameDirectoryNoReplace() error = nil, want existing destination failure")
|
||||||
|
}
|
||||||
|
assertFileBytes(t, filepath.Join(src, "value.txt"), []byte("source"))
|
||||||
|
assertFileBytes(t, filepath.Join(dst, "value.txt"), []byte("existing"))
|
||||||
|
if info, err := os.Stat(src); err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("source directory was not preserved: info=%v err=%v", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/fileops/rename_noreplace_windows.go
Normal file
19
internal/fileops/rename_noreplace_windows.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
import "golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
func checkAtomicDirectoryPromotionSupport() error { return nil }
|
||||||
|
|
||||||
|
func renameDirectoryNoReplace(src, dst string) error {
|
||||||
|
from, err := windows.UTF16PtrFromString(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
to, err := windows.UTF16PtrFromString(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return windows.MoveFileEx(from, to, 0)
|
||||||
|
}
|
||||||
25
internal/fileops/sync_directory_unix.go
Normal file
25
internal/fileops/sync_directory_unix.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func syncDirectory(path string) error {
|
||||||
|
directory, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = directory.Close() }()
|
||||||
|
|
||||||
|
err = directory.Sync()
|
||||||
|
// Some Unix filesystems do not implement directory syncing. Only their
|
||||||
|
// explicit unsupported-operation errors are safe to treat as best effort.
|
||||||
|
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
7
internal/fileops/sync_directory_unsupported.go
Normal file
7
internal/fileops/sync_directory_unsupported.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
func syncDirectory(string) error {
|
||||||
|
return checkAtomicDirectoryPromotionSupport()
|
||||||
|
}
|
||||||
40
internal/fileops/sync_directory_windows.go
Normal file
40
internal/fileops/sync_directory_windows.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package fileops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func syncDirectory(path string) error {
|
||||||
|
pathPointer, err := windows.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
directory, err := windows.CreateFile(
|
||||||
|
pathPointer,
|
||||||
|
windows.GENERIC_WRITE,
|
||||||
|
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||||
|
nil,
|
||||||
|
windows.OPEN_EXISTING,
|
||||||
|
windows.FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(directory) }()
|
||||||
|
|
||||||
|
err = windows.FlushFileBuffers(directory)
|
||||||
|
// Windows filesystems may reject flushing a directory handle even when it
|
||||||
|
// was opened correctly. Preserve every error except the documented forms
|
||||||
|
// that mean this operation is unavailable for the handle or filesystem.
|
||||||
|
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) ||
|
||||||
|
errors.Is(err, windows.ERROR_INVALID_HANDLE) ||
|
||||||
|
errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user