feat(agent/role): command runner and atomic file helpers for the roles
Shared, role-internal helpers: programs run without a shell, with a timeout and captured, truncated output quoted in errors; configuration files are replaced atomically (temp file, fsync, rename, directory sync) with explicit mode and the owner of the existing file or its directory; an in-memory backup restores touched files after a failed activation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e843502cbf
commit
b5e57c3cb7
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package command runs the external programs of the managed services
|
||||
// (named-checkconf, rndc, kea-dhcp4, ...). Programs are executed directly
|
||||
// with an argument list, never through a shell, with a timeout and with
|
||||
// their output captured up to a limit.
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limits for captured output.
|
||||
const (
|
||||
// MaxCapture is the number of output bytes kept from one run.
|
||||
MaxCapture = 256 << 10
|
||||
// MaxErrorOutput is the number of output bytes quoted in an error.
|
||||
MaxErrorOutput = 4 << 10
|
||||
)
|
||||
|
||||
// Runner runs a program and returns its combined stdout and stderr.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Exec runs programs on the host. Timeout bounds every run (0 means only
|
||||
// the context bounds it).
|
||||
type Exec struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Run implements Runner. A non-zero exit status, a timeout or a missing
|
||||
// program yields an *Error that quotes the (truncated) output.
|
||||
func (e Exec) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
if e.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
var out limitedBuffer
|
||||
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // programs come from the agent configuration, no shell is involved
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
// Do not wait forever for grandchildren that keep the pipes open.
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
err := cmd.Run()
|
||||
b := out.Bytes()
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
err = fmt.Errorf("%w (%w)", ctxErr, err)
|
||||
}
|
||||
return b, &Error{Name: name, Args: args, Err: err, Output: b}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Error describes a failed run.
|
||||
type Error struct {
|
||||
Name string
|
||||
Args []string
|
||||
Err error
|
||||
Output []byte
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(e.Name)
|
||||
for _, a := range e.Args {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a)
|
||||
}
|
||||
b.WriteString(": ")
|
||||
b.WriteString(e.Err.Error())
|
||||
if out := Excerpt(e.Output); out != "" {
|
||||
b.WriteString(": ")
|
||||
b.WriteString(out)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
|
||||
// Excerpt returns trimmed output for messages, cut to MaxErrorOutput bytes.
|
||||
// The end of the output is kept because the checkers print the decisive
|
||||
// line last.
|
||||
func Excerpt(out []byte) string {
|
||||
s := strings.TrimSpace(string(out))
|
||||
if len(s) > MaxErrorOutput {
|
||||
s = "..." + s[len(s)-MaxErrorOutput:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsNotFound reports whether err says that the program does not exist.
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, exec.ErrNotFound)
|
||||
}
|
||||
|
||||
// limitedBuffer keeps the first MaxCapture bytes and discards the rest.
|
||||
type limitedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (l *limitedBuffer) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if room := MaxCapture - l.buf.Len(); room > 0 {
|
||||
if len(p) > room {
|
||||
l.buf.Write(p[:room])
|
||||
l.truncated = true
|
||||
} else {
|
||||
l.buf.Write(p)
|
||||
}
|
||||
} else if len(p) > 0 {
|
||||
l.truncated = true
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (l *limitedBuffer) Bytes() []byte {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
b := bytes.Clone(l.buf.Bytes())
|
||||
if l.truncated {
|
||||
b = append(b, "\n[output truncated]"...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMissingProgram(t *testing.T) {
|
||||
_, err := Exec{Timeout: time.Second}.Run(context.Background(), "ddi-no-such-program-xyz", "-v")
|
||||
var ce *Error
|
||||
if !errors.As(err, &ce) || !IsNotFound(err) {
|
||||
t.Fatalf("err = %v, want *Error wrapping exec.ErrNotFound", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ddi-no-such-program-xyz -v") {
|
||||
t.Errorf("error does not name the command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailingProgramQuotesOutput(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("sh not available")
|
||||
}
|
||||
out, err := Exec{Timeout: 10 * time.Second}.Run(context.Background(), sh, "-c", "echo out; echo err >&2; exit 3")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(string(out), "out") || !strings.Contains(string(out), "err") {
|
||||
t.Errorf("output = %q", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exit status 3") || !strings.Contains(err.Error(), "err") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("sh not available")
|
||||
}
|
||||
start := time.Now()
|
||||
_, err = Exec{Timeout: 200 * time.Millisecond}.Run(context.Background(), sh, "-c", "sleep 10")
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want deadline exceeded", err)
|
||||
}
|
||||
if time.Since(start) > 8*time.Second {
|
||||
t.Error("timeout did not stop the program")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcerptAndLimit(t *testing.T) {
|
||||
long := strings.Repeat("a", MaxErrorOutput) + "END"
|
||||
ex := Excerpt([]byte(long))
|
||||
if !strings.HasPrefix(ex, "...") || !strings.HasSuffix(ex, "END") || len(ex) != MaxErrorOutput+3 {
|
||||
t.Errorf("excerpt has length %d, prefix %q", len(ex), ex[:5])
|
||||
}
|
||||
var l limitedBuffer
|
||||
_, _ = l.Write([]byte(strings.Repeat("b", MaxCapture-1)))
|
||||
_, _ = l.Write([]byte("cc"))
|
||||
_, _ = l.Write([]byte("dd"))
|
||||
b := l.Bytes()
|
||||
if !strings.HasSuffix(string(b), "[output truncated]") || strings.Count(string(b), "c") != 1 {
|
||||
t.Errorf("limited buffer kept %d bytes", len(b))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package fsutil writes configuration files atomically and keeps a backup of
|
||||
// the files an activation touches so that it can be undone.
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// tempPrefix starts the names of temporary files next to the targets.
|
||||
const tempPrefix = ".ddi-tmp-"
|
||||
|
||||
// Owner is a numeric file owner. Valid is false where ownership is unknown
|
||||
// (non-Unix systems).
|
||||
type Owner struct {
|
||||
UID, GID int
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// OwnerFor returns the owner a file written to path should get: the owner
|
||||
// of the existing file, otherwise the owner of its directory. Installers
|
||||
// thereby decide ownership once by creating the directories (e.g.
|
||||
// root:bind for configuration, bind:bind for zone files).
|
||||
func OwnerFor(path string) Owner {
|
||||
if o, ok := ownerOf(path); ok {
|
||||
return o
|
||||
}
|
||||
if o, ok := ownerOf(filepath.Dir(path)); ok {
|
||||
return o
|
||||
}
|
||||
return Owner{}
|
||||
}
|
||||
|
||||
// WriteFile replaces path atomically: it writes a temporary file in the same
|
||||
// directory, sets mode and owner, syncs it, renames it over path and syncs
|
||||
// the directory. The owner is applied only when the process runs as root.
|
||||
func WriteFile(path string, data []byte, mode fs.FileMode, owner Owner) (err error) {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, tempPrefix+"*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}()
|
||||
if _, err = f.Write(data); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
if err = f.Chmod(mode.Perm()); err != nil {
|
||||
return fmt.Errorf("chmod %s: %w", path, err)
|
||||
}
|
||||
if err = chown(f, owner); err != nil {
|
||||
return fmt.Errorf("chown %s: %w", path, err)
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync %s: %w", path, err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", path, err)
|
||||
}
|
||||
if err = os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("rename %s: %w", path, err)
|
||||
}
|
||||
return SyncDir(dir)
|
||||
}
|
||||
|
||||
// Remove deletes path (absent files are fine) and syncs its directory.
|
||||
func Remove(path string) error {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return SyncDir(filepath.Dir(path))
|
||||
}
|
||||
|
||||
// Exists reports whether something exists at path (without following a
|
||||
// final symbolic link).
|
||||
func Exists(path string) (bool, error) {
|
||||
_, err := os.Lstat(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// ReadFile reads a file of a revision or a live configuration file. The
|
||||
// paths come from the agent configuration and from revision manifests that
|
||||
// passed bundle.CheckPath.
|
||||
func ReadFile(path string) ([]byte, error) {
|
||||
return os.ReadFile(path) //nolint:gosec // see above
|
||||
}
|
||||
|
||||
// Same reports whether path is a regular file with exactly this content and
|
||||
// permission bits.
|
||||
func Same(path string, data []byte, mode fs.FileMode) bool {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil || !fi.Mode().IsRegular() || fi.Size() != int64(len(data)) || fi.Mode().Perm() != mode.Perm() {
|
||||
return false
|
||||
}
|
||||
cur, err := ReadFile(path)
|
||||
return err == nil && bytes.Equal(cur, data)
|
||||
}
|
||||
|
||||
// RequireDir returns an error unless path is an existing directory.
|
||||
func RequireDir(path string) error {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup remembers the state of files before they are changed: content,
|
||||
// mode and owner of existing files, or that a file did not exist. It is
|
||||
// held in memory; configuration files are small, and after a crash the
|
||||
// agent core rolls back to the last good revision anyway.
|
||||
type Backup struct {
|
||||
order []string
|
||||
saved map[string]saved
|
||||
}
|
||||
|
||||
type saved struct {
|
||||
exists bool
|
||||
data []byte
|
||||
mode fs.FileMode
|
||||
owner Owner
|
||||
}
|
||||
|
||||
// Save records path unless it was recorded before. Only regular files can
|
||||
// be saved; anything else at path is an error, so the caller never replaces
|
||||
// a directory or a symbolic link it could not restore.
|
||||
func (b *Backup) Save(path string) error {
|
||||
if _, ok := b.saved[path]; ok {
|
||||
return nil
|
||||
}
|
||||
if b.saved == nil {
|
||||
b.saved = map[string]saved{}
|
||||
}
|
||||
fi, err := os.Lstat(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
b.saved[path] = saved{}
|
||||
case err != nil:
|
||||
return fmt.Errorf("back up %s: %w", path, err)
|
||||
case !fi.Mode().IsRegular():
|
||||
return fmt.Errorf("back up %s: not a regular file (%s)", path, fi.Mode().Type())
|
||||
default:
|
||||
data, err := ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("back up %s: %w", path, err)
|
||||
}
|
||||
o, _ := ownerOf(path)
|
||||
b.saved[path] = saved{exists: true, data: data, mode: fi.Mode().Perm(), owner: o}
|
||||
}
|
||||
b.order = append(b.order, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Paths returns the recorded paths in the order they were saved.
|
||||
func (b *Backup) Paths() []string { return append([]string(nil), b.order...) }
|
||||
|
||||
// Restore puts every recorded file back: existing files get their old
|
||||
// content, mode and owner, files that did not exist are removed. It goes
|
||||
// on after errors and returns all of them.
|
||||
func (b *Backup) Restore() error {
|
||||
var errs []error
|
||||
for i := len(b.order) - 1; i >= 0; i-- {
|
||||
p := b.order[i]
|
||||
s := b.saved[p]
|
||||
if s.exists {
|
||||
if err := WriteFile(p, s.data, s.mode, s.owner); err != nil {
|
||||
errs = append(errs, fmt.Errorf("restore %s: %w", p, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := Remove(p); err != nil {
|
||||
errs = append(errs, fmt.Errorf("restore %s (remove): %w", p, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build !unix
|
||||
|
||||
package fsutil
|
||||
|
||||
import "os"
|
||||
|
||||
// The agents run on Linux; on other systems (development builds) ownership
|
||||
// is not handled and directories are not synced.
|
||||
|
||||
func ownerOf(string) (Owner, bool) { return Owner{}, false }
|
||||
|
||||
func chown(*os.File, Owner) error { return nil }
|
||||
|
||||
// SyncDir is a no-op on systems without directory sync.
|
||||
func SyncDir(string) error { return nil }
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteFileReplacesAtomically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.conf")
|
||||
if err := os.WriteFile(p, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteFile(p, []byte("new"), 0o640, OwnerFor(p)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(p)
|
||||
if err != nil || string(got) != "new" {
|
||||
t.Fatalf("content = %q, %v", got, err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
fi, _ := os.Stat(p)
|
||||
if fi.Mode().Perm() != 0o640 {
|
||||
t.Errorf("mode = %o, want 640", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), tempPrefix) {
|
||||
t.Errorf("temporary file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileFailsWithoutDirectory(t *testing.T) {
|
||||
if err := WriteFile(filepath.Join(t.TempDir(), "missing", "f"), []byte("x"), 0o640, Owner{}); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupRestore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
existing := filepath.Join(dir, "existing")
|
||||
created := filepath.Join(dir, "created")
|
||||
removed := filepath.Join(dir, "removed")
|
||||
for _, p := range []string{existing, removed} {
|
||||
if err := os.WriteFile(p, []byte("orig "+filepath.Base(p)), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var b Backup
|
||||
for _, p := range []string{existing, created, removed, existing} {
|
||||
if err := b.Save(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := len(b.Paths()); got != 3 {
|
||||
t.Fatalf("saved %d paths, want 3 (duplicates ignored)", got)
|
||||
}
|
||||
// Change everything.
|
||||
if err := WriteFile(existing, []byte("changed"), 0o600, Owner{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteFile(created, []byte("new"), 0o600, Owner{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Remove(removed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Restore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for p, want := range map[string]string{existing: "orig existing", removed: "orig removed"} {
|
||||
got, err := os.ReadFile(p)
|
||||
if err != nil || string(got) != want {
|
||||
t.Errorf("%s = %q, %v; want %q", p, got, err, want)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
|
||||
t.Errorf("%s: mode %o not restored", p, fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok, _ := Exists(created); ok {
|
||||
t.Error("file created after the backup was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupRefusesDirectories(t *testing.T) {
|
||||
var b Backup
|
||||
if err := b.Save(t.TempDir()); err == nil {
|
||||
t.Fatal("expected an error for a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSame(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "f")
|
||||
if Same(p, []byte("x"), 0o640) {
|
||||
t.Error("missing file reported as same")
|
||||
}
|
||||
if err := WriteFile(p, []byte("x"), 0o640, Owner{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Same(p, []byte("x"), 0o640) && runtime.GOOS != "windows" {
|
||||
t.Error("identical file not recognized")
|
||||
}
|
||||
if Same(p, []byte("y"), 0o640) {
|
||||
t.Error("different content reported as same")
|
||||
}
|
||||
if runtime.GOOS != "windows" && Same(p, []byte("x"), 0o600) {
|
||||
t.Error("different mode reported as same")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := RequireDir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := filepath.Join(dir, "f")
|
||||
_ = os.WriteFile(f, nil, 0o600)
|
||||
if RequireDir(f) == nil || RequireDir(filepath.Join(dir, "nope")) == nil {
|
||||
t.Fatal("expected errors")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build unix
|
||||
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func ownerOf(path string) (Owner, bool) {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return Owner{}, false
|
||||
}
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return Owner{}, false
|
||||
}
|
||||
return Owner{UID: int(st.Uid), GID: int(st.Gid), Valid: true}, true
|
||||
}
|
||||
|
||||
// chown applies owner if the process may change ownership (root). Other
|
||||
// users cannot give files away, and their files belong to them anyway.
|
||||
func chown(f *os.File, owner Owner) error {
|
||||
if !owner.Valid || os.Geteuid() != 0 {
|
||||
return nil
|
||||
}
|
||||
return f.Chown(owner.UID, owner.GID)
|
||||
}
|
||||
|
||||
// SyncDir flushes directory entries (renames, removals) to disk.
|
||||
func SyncDir(dir string) error {
|
||||
d, err := os.Open(dir) //nolint:gosec // dir is a configured directory of the role
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = d.Close() }()
|
||||
return d.Sync()
|
||||
}
|
||||
Reference in New Issue
Block a user