Port envd from e2b with internalized shared packages and Connect RPC
- Copy envd source from e2b-dev/infra, internalize shared dependencies
into envd/internal/shared/ (keys, filesystem, id, smap, utils)
- Switch from gRPC to Connect RPC for all envd services
- Update module paths to git.omukk.dev/wrenn/{sandbox,sandbox/envd}
- Add proto specs (process, filesystem) with buf-based code generation
- Implement full envd: process exec, filesystem ops, port forwarding,
cgroup management, MMDS integration, and HTTP API
- Update main module dependencies (firecracker SDK, pgx, goose, etc.)
- Remove placeholder .gitkeep files replaced by real implementations
This commit is contained in:
184
envd/internal/services/filesystem/dir.go
Normal file
184
envd/internal/services/filesystem/dir.go
Normal file
@ -0,0 +1,184 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func (s Service) ListDir(ctx context.Context, req *connect.Request[rpc.ListDirRequest]) (*connect.Response[rpc.ListDirResponse], error) {
|
||||
depth := req.Msg.GetDepth()
|
||||
if depth == 0 {
|
||||
depth = 1 // default depth to current directory
|
||||
}
|
||||
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestedPath := req.Msg.GetPath()
|
||||
|
||||
// Expand the path so we can return absolute paths in the response.
|
||||
requestedPath, err = permissions.ExpandAndResolve(requestedPath, u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
resolvedPath, err := followSymlink(requestedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = checkIfDirectory(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := walkDir(requestedPath, resolvedPath, int(depth))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rpc.ListDirResponse{
|
||||
Entries: entries,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s Service) MakeDir(ctx context.Context, req *connect.Request[rpc.MakeDirRequest]) (*connect.Response[rpc.MakeDirResponse], error) {
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dirPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(dirPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err))
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if stat.IsDir() {
|
||||
return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("directory already exists: %s", dirPath))
|
||||
}
|
||||
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path already exists but it is not a directory: %s", dirPath))
|
||||
}
|
||||
|
||||
uid, gid, userErr := permissions.GetUserIdInts(u)
|
||||
if userErr != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, userErr)
|
||||
}
|
||||
|
||||
userErr = permissions.EnsureDirs(dirPath, uid, gid)
|
||||
if userErr != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, userErr)
|
||||
}
|
||||
|
||||
entry, err := entryInfo(dirPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rpc.MakeDirResponse{
|
||||
Entry: entry,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// followSymlink resolves a symbolic link to its target path.
|
||||
func followSymlink(path string) (string, error) {
|
||||
// Resolve symlinks
|
||||
resolvedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("path not found: %w", err))
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "too many links") {
|
||||
return "", connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("cyclic symlink or chain >255 links at %q", path))
|
||||
}
|
||||
|
||||
return "", connect.NewError(connect.CodeInternal, fmt.Errorf("error resolving symlink: %w", err))
|
||||
}
|
||||
|
||||
return resolvedPath, nil
|
||||
}
|
||||
|
||||
// checkIfDirectory checks if the given path is a directory.
|
||||
func checkIfDirectory(path string) error {
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return connect.NewError(connect.CodeNotFound, fmt.Errorf("directory not found: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err))
|
||||
}
|
||||
|
||||
if !stat.IsDir() {
|
||||
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path is not a directory: %s", path))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkDir walks the directory tree starting from dirPath up to the specified depth (doesn't follow symlinks).
|
||||
func walkDir(requestedPath string, dirPath string, depth int) (entries []*rpc.EntryInfo, err error) {
|
||||
err = filepath.WalkDir(dirPath, func(path string, _ os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the root directory itself
|
||||
if path == dirPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate current depth
|
||||
relPath, err := filepath.Rel(dirPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentDepth := len(strings.Split(relPath, string(os.PathSeparator)))
|
||||
|
||||
if currentDepth > depth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
entryInfo, err := entryInfo(path)
|
||||
if err != nil {
|
||||
var connectErr *connect.Error
|
||||
if errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound {
|
||||
// Skip entries that don't exist anymore
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Return the requested path as the base path instead of the symlink-resolved path
|
||||
path = filepath.Join(requestedPath, relPath)
|
||||
entryInfo.Path = path
|
||||
|
||||
entries = append(entries, entryInfo)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error reading directory %s: %w", dirPath, err))
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
405
envd/internal/services/filesystem/dir_test.go
Normal file
405
envd/internal/services/filesystem/dir_test.go
Normal file
@ -0,0 +1,405 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/authn"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func TestListDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup temp root and user
|
||||
root := t.TempDir()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup directory structure
|
||||
testFolder := filepath.Join(root, "test")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-1"), 0o755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-2"), 0o755))
|
||||
filePath := filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt")
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644))
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
|
||||
// Helper to inject user into context
|
||||
injectUser := func(ctx context.Context, u *user.User) context.Context {
|
||||
return authn.SetInfo(ctx, u)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
depth uint32
|
||||
expectedPaths []string
|
||||
}{
|
||||
{
|
||||
name: "depth 0 lists only root directory",
|
||||
depth: 0,
|
||||
expectedPaths: []string{
|
||||
filepath.Join(testFolder, "test-dir"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "depth 1 lists root directory",
|
||||
depth: 1,
|
||||
expectedPaths: []string{
|
||||
filepath.Join(testFolder, "test-dir"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "depth 2 lists first level of subdirectories (in this case the root directory)",
|
||||
depth: 2,
|
||||
expectedPaths: []string{
|
||||
filepath.Join(testFolder, "test-dir"),
|
||||
filepath.Join(testFolder, "test-dir", "sub-dir-1"),
|
||||
filepath.Join(testFolder, "test-dir", "sub-dir-2"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "depth 3 lists all directories and files",
|
||||
depth: 3,
|
||||
expectedPaths: []string{
|
||||
filepath.Join(testFolder, "test-dir"),
|
||||
filepath.Join(testFolder, "test-dir", "sub-dir-1"),
|
||||
filepath.Join(testFolder, "test-dir", "sub-dir-2"),
|
||||
filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := injectUser(t.Context(), u)
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: testFolder,
|
||||
Depth: tt.depth,
|
||||
})
|
||||
resp, err := svc.ListDir(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.Msg)
|
||||
assert.Len(t, resp.Msg.GetEntries(), len(tt.expectedPaths))
|
||||
actualPaths := make([]string, len(resp.Msg.GetEntries()))
|
||||
for i, entry := range resp.Msg.GetEntries() {
|
||||
actualPaths[i] = entry.GetPath()
|
||||
}
|
||||
assert.ElementsMatch(t, tt.expectedPaths, actualPaths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirNonExistingPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := mockService()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: "/non-existing-path",
|
||||
Depth: 1,
|
||||
})
|
||||
_, err = svc.ListDir(ctx, req)
|
||||
require.Error(t, err)
|
||||
var connectErr *connect.Error
|
||||
ok := errors.As(err, &connectErr)
|
||||
assert.True(t, ok, "expected error to be of type *connect.Error")
|
||||
assert.Equal(t, connect.CodeNotFound, connectErr.Code())
|
||||
}
|
||||
|
||||
func TestListDirRelativePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup temp root and user
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup directory structure
|
||||
testRelativePath := fmt.Sprintf("test-%s", uuid.New())
|
||||
testFolderPath := filepath.Join(u.HomeDir, testRelativePath)
|
||||
filePath := filepath.Join(testFolderPath, "file.txt")
|
||||
require.NoError(t, os.MkdirAll(testFolderPath, 0o755))
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644))
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: testRelativePath,
|
||||
Depth: 1,
|
||||
})
|
||||
resp, err := svc.ListDir(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.Msg)
|
||||
|
||||
expectedPaths := []string{
|
||||
filepath.Join(testFolderPath, "file.txt"),
|
||||
}
|
||||
assert.Len(t, resp.Msg.GetEntries(), len(expectedPaths))
|
||||
|
||||
actualPaths := make([]string, len(resp.Msg.GetEntries()))
|
||||
for i, entry := range resp.Msg.GetEntries() {
|
||||
actualPaths[i] = entry.GetPath()
|
||||
}
|
||||
assert.ElementsMatch(t, expectedPaths, actualPaths)
|
||||
}
|
||||
|
||||
func TestListDir_Symlinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
|
||||
symlinkRoot := filepath.Join(root, "test-symlinks")
|
||||
require.NoError(t, os.MkdirAll(symlinkRoot, 0o755))
|
||||
|
||||
// 1. Prepare a real directory + file that a symlink will point to
|
||||
realDir := filepath.Join(symlinkRoot, "real-dir")
|
||||
require.NoError(t, os.MkdirAll(realDir, 0o755))
|
||||
filePath := filepath.Join(realDir, "file.txt")
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644))
|
||||
|
||||
// 2. Prepare a standalone real file (points-to-file scenario)
|
||||
realFile := filepath.Join(symlinkRoot, "real-file.txt")
|
||||
require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644))
|
||||
|
||||
// 3. Create the three symlinks
|
||||
linkToDir := filepath.Join(symlinkRoot, "link-dir") // → directory
|
||||
linkToFile := filepath.Join(symlinkRoot, "link-file") // → file
|
||||
cyclicLink := filepath.Join(symlinkRoot, "cyclic") // → itself
|
||||
require.NoError(t, os.Symlink(realDir, linkToDir))
|
||||
require.NoError(t, os.Symlink(realFile, linkToFile))
|
||||
require.NoError(t, os.Symlink(cyclicLink, cyclicLink))
|
||||
|
||||
svc := mockService()
|
||||
|
||||
t.Run("symlink to directory behaves like directory and the content looks like inside the directory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: linkToDir,
|
||||
Depth: 1,
|
||||
})
|
||||
resp, err := svc.ListDir(ctx, req)
|
||||
require.NoError(t, err)
|
||||
expected := []string{
|
||||
filepath.Join(linkToDir, "file.txt"),
|
||||
}
|
||||
actual := make([]string, len(resp.Msg.GetEntries()))
|
||||
for i, e := range resp.Msg.GetEntries() {
|
||||
actual[i] = e.GetPath()
|
||||
}
|
||||
assert.ElementsMatch(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("link to file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: linkToFile,
|
||||
Depth: 1,
|
||||
})
|
||||
_, err := svc.ListDir(ctx, req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a directory")
|
||||
})
|
||||
|
||||
t.Run("cyclic symlink surfaces 'too many links' → invalid-argument", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: cyclicLink,
|
||||
})
|
||||
_, err := svc.ListDir(ctx, req)
|
||||
require.Error(t, err)
|
||||
var connectErr *connect.Error
|
||||
ok := errors.As(err, &connectErr)
|
||||
assert.True(t, ok, "expected error to be of type *connect.Error")
|
||||
assert.Equal(t, connect.CodeFailedPrecondition, connectErr.Code())
|
||||
assert.Contains(t, connectErr.Error(), "cyclic symlink")
|
||||
})
|
||||
|
||||
t.Run("symlink not resolved if not root", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := connect.NewRequest(&filesystem.ListDirRequest{
|
||||
Path: symlinkRoot,
|
||||
Depth: 3,
|
||||
})
|
||||
res, err := svc.ListDir(ctx, req)
|
||||
require.NoError(t, err)
|
||||
expected := []string{
|
||||
filepath.Join(symlinkRoot, "cyclic"),
|
||||
filepath.Join(symlinkRoot, "link-dir"),
|
||||
filepath.Join(symlinkRoot, "link-file"),
|
||||
filepath.Join(symlinkRoot, "real-dir"),
|
||||
filepath.Join(symlinkRoot, "real-dir", "file.txt"),
|
||||
filepath.Join(symlinkRoot, "real-file.txt"),
|
||||
}
|
||||
actual := make([]string, len(res.Msg.GetEntries()))
|
||||
for i, e := range res.Msg.GetEntries() {
|
||||
actual[i] = e.GetPath()
|
||||
}
|
||||
assert.ElementsMatch(t, expected, actual, "symlinks should not be resolved when listing the symlink root directory")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFollowSymlink_Success makes sure that followSymlink resolves symlinks,
|
||||
// while also being robust to the /var → /private/var indirection that exists on macOS.
|
||||
func TestFollowSymlink_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Base temporary directory. On macOS this lives under /var/folders/…
|
||||
// which itself is a symlink to /private/var/folders/….
|
||||
base := t.TempDir()
|
||||
|
||||
// Create a real directory that we ultimately want to resolve to.
|
||||
target := filepath.Join(base, "target")
|
||||
require.NoError(t, os.MkdirAll(target, 0o755))
|
||||
|
||||
// Create a symlink pointing at the real directory so we can verify that
|
||||
// followSymlink follows it.
|
||||
link := filepath.Join(base, "link")
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
got, err := followSymlink(link)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Canonicalise the expected path too, so that /var → /private/var (macOS)
|
||||
// or any other benign symlink indirections don’t cause flaky tests.
|
||||
want, err := filepath.EvalSymlinks(link)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, want, got, "followSymlink should resolve and canonicalise symlinks")
|
||||
}
|
||||
|
||||
// TestFollowSymlink_MultiSymlinkChain verifies that followSymlink follows a chain
|
||||
// of several symlinks (non‑cyclic) correctly.
|
||||
func TestFollowSymlink_MultiSymlinkChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := t.TempDir()
|
||||
|
||||
// Final destination directory.
|
||||
target := filepath.Join(base, "target")
|
||||
require.NoError(t, os.MkdirAll(target, 0o755))
|
||||
|
||||
// Build a 3‑link chain: link1 → link2 → link3 → target.
|
||||
link3 := filepath.Join(base, "link3")
|
||||
require.NoError(t, os.Symlink(target, link3))
|
||||
|
||||
link2 := filepath.Join(base, "link2")
|
||||
require.NoError(t, os.Symlink(link3, link2))
|
||||
|
||||
link1 := filepath.Join(base, "link1")
|
||||
require.NoError(t, os.Symlink(link2, link1))
|
||||
|
||||
got, err := followSymlink(link1)
|
||||
require.NoError(t, err)
|
||||
|
||||
want, err := filepath.EvalSymlinks(link1)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, want, got, "followSymlink should resolve an arbitrary symlink chain")
|
||||
}
|
||||
|
||||
func TestFollowSymlink_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := followSymlink("/definitely/does/not/exist")
|
||||
require.Error(t, err)
|
||||
|
||||
var cerr *connect.Error
|
||||
require.ErrorAs(t, err, &cerr)
|
||||
require.Equal(t, connect.CodeNotFound, cerr.Code())
|
||||
}
|
||||
|
||||
func TestFollowSymlink_CyclicSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a")
|
||||
b := filepath.Join(dir, "b")
|
||||
require.NoError(t, os.MkdirAll(a, 0o755))
|
||||
require.NoError(t, os.MkdirAll(b, 0o755))
|
||||
|
||||
// Create a two‑node loop: a/loop → b/loop, b/loop → a/loop.
|
||||
require.NoError(t, os.Symlink(filepath.Join(b, "loop"), filepath.Join(a, "loop")))
|
||||
require.NoError(t, os.Symlink(filepath.Join(a, "loop"), filepath.Join(b, "loop")))
|
||||
|
||||
_, err := followSymlink(filepath.Join(a, "loop"))
|
||||
require.Error(t, err)
|
||||
|
||||
var cerr *connect.Error
|
||||
require.ErrorAs(t, err, &cerr)
|
||||
require.Equal(t, connect.CodeFailedPrecondition, cerr.Code())
|
||||
require.Contains(t, cerr.Message(), "cyclic")
|
||||
}
|
||||
|
||||
func TestCheckIfDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, checkIfDirectory(dir))
|
||||
|
||||
file := filepath.Join(dir, "file.txt")
|
||||
require.NoError(t, os.WriteFile(file, []byte("hello"), 0o644))
|
||||
|
||||
err := checkIfDirectory(file)
|
||||
require.Error(t, err)
|
||||
|
||||
var cerr *connect.Error
|
||||
require.ErrorAs(t, err, &cerr)
|
||||
require.Equal(t, connect.CodeInvalidArgument, cerr.Code())
|
||||
}
|
||||
|
||||
func TestWalkDir_Depth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "sub")
|
||||
subsub := filepath.Join(sub, "subsub")
|
||||
require.NoError(t, os.MkdirAll(subsub, 0o755))
|
||||
|
||||
entries, err := walkDir(root, root, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Collect the names for easier assertions.
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.GetName())
|
||||
}
|
||||
|
||||
require.Contains(t, names, "sub")
|
||||
require.NotContains(t, names, "subsub", "entries beyond depth should be excluded")
|
||||
}
|
||||
|
||||
func TestWalkDir_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := walkDir("/does/not/exist", "/does/not/exist", 1)
|
||||
require.Error(t, err)
|
||||
|
||||
var cerr *connect.Error
|
||||
require.ErrorAs(t, err, &cerr)
|
||||
require.Equal(t, connect.CodeInternal, cerr.Code())
|
||||
}
|
||||
58
envd/internal/services/filesystem/move.go
Normal file
58
envd/internal/services/filesystem/move.go
Normal file
@ -0,0 +1,58 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func (s Service) Move(ctx context.Context, req *connect.Request[rpc.MoveRequest]) (*connect.Response[rpc.MoveResponse], error) {
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
source, err := permissions.ExpandAndResolve(req.Msg.GetSource(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
destination, err := permissions.ExpandAndResolve(req.Msg.GetDestination(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
uid, gid, userErr := permissions.GetUserIdInts(u)
|
||||
if userErr != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, userErr)
|
||||
}
|
||||
|
||||
userErr = permissions.EnsureDirs(filepath.Dir(destination), uid, gid)
|
||||
if userErr != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, userErr)
|
||||
}
|
||||
|
||||
err = os.Rename(source, destination)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("source file not found: %w", err))
|
||||
}
|
||||
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error renaming: %w", err))
|
||||
}
|
||||
|
||||
entry, err := entryInfo(destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rpc.MoveResponse{
|
||||
Entry: entry,
|
||||
}), nil
|
||||
}
|
||||
364
envd/internal/services/filesystem/move_test.go
Normal file
364
envd/internal/services/filesystem/move_test.go
Normal file
@ -0,0 +1,364 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/authn"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func TestMove(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup temp root and user
|
||||
root := t.TempDir()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup source and destination directories
|
||||
sourceDir := filepath.Join(root, "source")
|
||||
destDir := filepath.Join(root, "destination")
|
||||
require.NoError(t, os.MkdirAll(sourceDir, 0o755))
|
||||
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||
|
||||
// Create a test file to move
|
||||
sourceFile := filepath.Join(sourceDir, "test-file.txt")
|
||||
testContent := []byte("Hello, World!")
|
||||
require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644))
|
||||
|
||||
// Destination file path
|
||||
destFile := filepath.Join(destDir, "test-file.txt")
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
|
||||
// Call the Move function
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: sourceFile,
|
||||
Destination: destFile,
|
||||
})
|
||||
resp, err := svc.Move(ctx, req)
|
||||
|
||||
// Verify the move was successful
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp)
|
||||
assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath())
|
||||
|
||||
// Verify the file exists at the destination
|
||||
_, err = os.Stat(destFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the file no longer exists at the source
|
||||
_, err = os.Stat(sourceFile)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify the content of the moved file
|
||||
content, err := os.ReadFile(destFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testContent, content)
|
||||
}
|
||||
|
||||
func TestMoveDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup temp root and user
|
||||
root := t.TempDir()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup source and destination directories
|
||||
sourceParent := filepath.Join(root, "source-parent")
|
||||
destParent := filepath.Join(root, "dest-parent")
|
||||
require.NoError(t, os.MkdirAll(sourceParent, 0o755))
|
||||
require.NoError(t, os.MkdirAll(destParent, 0o755))
|
||||
|
||||
// Create a test directory with files to move
|
||||
sourceDir := filepath.Join(sourceParent, "test-dir")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "subdir"), 0o755))
|
||||
|
||||
// Create some files in the directory
|
||||
file1 := filepath.Join(sourceDir, "file1.txt")
|
||||
file2 := filepath.Join(sourceDir, "subdir", "file2.txt")
|
||||
require.NoError(t, os.WriteFile(file1, []byte("File 1 content"), 0o644))
|
||||
require.NoError(t, os.WriteFile(file2, []byte("File 2 content"), 0o644))
|
||||
|
||||
// Destination directory path
|
||||
destDir := filepath.Join(destParent, "test-dir")
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
|
||||
// Call the Move function
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: sourceDir,
|
||||
Destination: destDir,
|
||||
})
|
||||
resp, err := svc.Move(ctx, req)
|
||||
|
||||
// Verify the move was successful
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp)
|
||||
assert.Equal(t, destDir, resp.Msg.GetEntry().GetPath())
|
||||
|
||||
// Verify the directory exists at the destination
|
||||
_, err = os.Stat(destDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the files exist at the destination
|
||||
destFile1 := filepath.Join(destDir, "file1.txt")
|
||||
destFile2 := filepath.Join(destDir, "subdir", "file2.txt")
|
||||
_, err = os.Stat(destFile1)
|
||||
require.NoError(t, err)
|
||||
_, err = os.Stat(destFile2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the directory no longer exists at the source
|
||||
_, err = os.Stat(sourceDir)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify the content of the moved files
|
||||
content1, err := os.ReadFile(destFile1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("File 1 content"), content1)
|
||||
|
||||
content2, err := os.ReadFile(destFile2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("File 2 content"), content2)
|
||||
}
|
||||
|
||||
func TestMoveNonExistingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup temp root and user
|
||||
root := t.TempDir()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup destination directory
|
||||
destDir := filepath.Join(root, "destination")
|
||||
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||
|
||||
// Non-existing source file
|
||||
sourceFile := filepath.Join(root, "non-existing-file.txt")
|
||||
|
||||
// Destination file path
|
||||
destFile := filepath.Join(destDir, "moved-file.txt")
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
|
||||
// Call the Move function
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: sourceFile,
|
||||
Destination: destFile,
|
||||
})
|
||||
_, err = svc.Move(ctx, req)
|
||||
|
||||
// Verify the correct error is returned
|
||||
require.Error(t, err)
|
||||
|
||||
var connectErr *connect.Error
|
||||
ok := errors.As(err, &connectErr)
|
||||
assert.True(t, ok, "expected error to be of type *connect.Error")
|
||||
assert.Equal(t, connect.CodeNotFound, connectErr.Code())
|
||||
assert.Contains(t, connectErr.Message(), "source file not found")
|
||||
}
|
||||
|
||||
func TestMoveRelativePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup user
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup directory structure with unique name to avoid conflicts
|
||||
testRelativePath := fmt.Sprintf("test-move-%s", uuid.New())
|
||||
testFolderPath := filepath.Join(u.HomeDir, testRelativePath)
|
||||
require.NoError(t, os.MkdirAll(testFolderPath, 0o755))
|
||||
|
||||
// Create a test file to move
|
||||
sourceFile := filepath.Join(testFolderPath, "source-file.txt")
|
||||
testContent := []byte("Hello from relative path!")
|
||||
require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644))
|
||||
|
||||
// Destination file path (also relative)
|
||||
destRelativePath := fmt.Sprintf("test-move-dest-%s", uuid.New())
|
||||
destFolderPath := filepath.Join(u.HomeDir, destRelativePath)
|
||||
require.NoError(t, os.MkdirAll(destFolderPath, 0o755))
|
||||
destFile := filepath.Join(destFolderPath, "moved-file.txt")
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
|
||||
// Call the Move function with relative paths
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: filepath.Join(testRelativePath, "source-file.txt"), // Relative path
|
||||
Destination: filepath.Join(destRelativePath, "moved-file.txt"), // Relative path
|
||||
})
|
||||
resp, err := svc.Move(ctx, req)
|
||||
|
||||
// Verify the move was successful
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp)
|
||||
assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath())
|
||||
|
||||
// Verify the file exists at the destination
|
||||
_, err = os.Stat(destFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the file no longer exists at the source
|
||||
_, err = os.Stat(sourceFile)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify the content of the moved file
|
||||
content, err := os.ReadFile(destFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testContent, content)
|
||||
|
||||
// Clean up
|
||||
os.RemoveAll(testFolderPath)
|
||||
os.RemoveAll(destFolderPath)
|
||||
}
|
||||
|
||||
func TestMove_Symlinks(t *testing.T) { //nolint:tparallel // this test cannot be executed in parallel
|
||||
root := t.TempDir()
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
|
||||
// Setup source and destination directories
|
||||
sourceRoot := filepath.Join(root, "source")
|
||||
destRoot := filepath.Join(root, "destination")
|
||||
require.NoError(t, os.MkdirAll(sourceRoot, 0o755))
|
||||
require.NoError(t, os.MkdirAll(destRoot, 0o755))
|
||||
|
||||
// 1. Prepare a real directory + file that a symlink will point to
|
||||
realDir := filepath.Join(sourceRoot, "real-dir")
|
||||
require.NoError(t, os.MkdirAll(realDir, 0o755))
|
||||
filePath := filepath.Join(realDir, "file.txt")
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644))
|
||||
|
||||
// 2. Prepare a standalone real file (points-to-file scenario)
|
||||
realFile := filepath.Join(sourceRoot, "real-file.txt")
|
||||
require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644))
|
||||
|
||||
// 3. Create symlinks
|
||||
linkToDir := filepath.Join(sourceRoot, "link-dir") // → directory
|
||||
linkToFile := filepath.Join(sourceRoot, "link-file") // → file
|
||||
require.NoError(t, os.Symlink(realDir, linkToDir))
|
||||
require.NoError(t, os.Symlink(realFile, linkToFile))
|
||||
|
||||
svc := mockService()
|
||||
|
||||
t.Run("move symlink to directory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
destPath := filepath.Join(destRoot, "moved-link-dir")
|
||||
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: linkToDir,
|
||||
Destination: destPath,
|
||||
})
|
||||
resp, err := svc.Move(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath())
|
||||
|
||||
// Verify the symlink was moved
|
||||
_, err = os.Stat(destPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's still a symlink
|
||||
info, err := os.Lstat(destPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink")
|
||||
|
||||
// Verify the symlink target is still correct
|
||||
target, err := os.Readlink(destPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, realDir, target)
|
||||
|
||||
// Verify the original symlink is gone
|
||||
_, err = os.Stat(linkToDir)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify the real directory still exists
|
||||
_, err = os.Stat(realDir)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("move symlink to file", func(t *testing.T) { //nolint:paralleltest
|
||||
destPath := filepath.Join(destRoot, "moved-link-file")
|
||||
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: linkToFile,
|
||||
Destination: destPath,
|
||||
})
|
||||
resp, err := svc.Move(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath())
|
||||
|
||||
// Verify the symlink was moved
|
||||
_, err = os.Stat(destPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's still a symlink
|
||||
info, err := os.Lstat(destPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink")
|
||||
|
||||
// Verify the symlink target is still correct
|
||||
target, err := os.Readlink(destPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, realFile, target)
|
||||
|
||||
// Verify the original symlink is gone
|
||||
_, err = os.Stat(linkToFile)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify the real file still exists
|
||||
_, err = os.Stat(realFile)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("move real file that is target of symlink", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create a new symlink to the real file
|
||||
newLinkToFile := filepath.Join(sourceRoot, "new-link-file")
|
||||
require.NoError(t, os.Symlink(realFile, newLinkToFile))
|
||||
|
||||
destPath := filepath.Join(destRoot, "moved-real-file.txt")
|
||||
|
||||
req := connect.NewRequest(&filesystem.MoveRequest{
|
||||
Source: realFile,
|
||||
Destination: destPath,
|
||||
})
|
||||
resp, err := svc.Move(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath())
|
||||
|
||||
// Verify the real file was moved
|
||||
_, err = os.Stat(destPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the original file is gone
|
||||
_, err = os.Stat(realFile)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify the symlink still exists but now points to a non-existent file
|
||||
_, err = os.Stat(newLinkToFile)
|
||||
require.Error(t, err, "symlink should point to non-existent file")
|
||||
})
|
||||
}
|
||||
31
envd/internal/services/filesystem/remove.go
Normal file
31
envd/internal/services/filesystem/remove.go
Normal file
@ -0,0 +1,31 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func (s Service) Remove(ctx context.Context, req *connect.Request[rpc.RemoveRequest]) (*connect.Response[rpc.RemoveResponse], error) {
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(path)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error removing file or directory: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rpc.RemoveResponse{}), nil
|
||||
}
|
||||
34
envd/internal/services/filesystem/service.go
Normal file
34
envd/internal/services/filesystem/service.go
Normal file
@ -0,0 +1,34 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"connectrpc.com/connect"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/execcontext"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/logs"
|
||||
spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem/filesystemconnect"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger *zerolog.Logger
|
||||
watchers *utils.Map[string, *FileWatcher]
|
||||
defaults *execcontext.Defaults
|
||||
}
|
||||
|
||||
func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults) {
|
||||
service := Service{
|
||||
logger: l,
|
||||
watchers: utils.NewMap[string, *FileWatcher](),
|
||||
defaults: defaults,
|
||||
}
|
||||
|
||||
interceptors := connect.WithInterceptors(
|
||||
logs.NewUnaryLogInterceptor(l),
|
||||
)
|
||||
|
||||
path, handler := spec.NewFilesystemHandler(service, interceptors)
|
||||
|
||||
server.Mount(path, handler)
|
||||
}
|
||||
14
envd/internal/services/filesystem/service_test.go
Normal file
14
envd/internal/services/filesystem/service_test.go
Normal file
@ -0,0 +1,14 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/execcontext"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
|
||||
)
|
||||
|
||||
func mockService() Service {
|
||||
return Service{
|
||||
defaults: &execcontext.Defaults{
|
||||
EnvVars: utils.NewMap[string, string](),
|
||||
},
|
||||
}
|
||||
}
|
||||
29
envd/internal/services/filesystem/stat.go
Normal file
29
envd/internal/services/filesystem/stat.go
Normal file
@ -0,0 +1,29 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func (s Service) Stat(ctx context.Context, req *connect.Request[rpc.StatRequest]) (*connect.Response[rpc.StatResponse], error) {
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
entry, err := entryInfo(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rpc.StatResponse{Entry: entry}), nil
|
||||
}
|
||||
114
envd/internal/services/filesystem/stat_test.go
Normal file
114
envd/internal/services/filesystem/stat_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/authn"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
)
|
||||
|
||||
func TestStat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup temp root and user
|
||||
root := t.TempDir()
|
||||
// Get the actual path to the temp directory (symlinks can cause issues)
|
||||
root, err := filepath.EvalSymlinks(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
group, err := user.LookupGroupId(u.Gid)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup directory structure
|
||||
testFolder := filepath.Join(root, "test")
|
||||
err = os.MkdirAll(testFolder, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
testFile := filepath.Join(testFolder, "file.txt")
|
||||
err = os.WriteFile(testFile, []byte("Hello, World!"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
linkedFile := filepath.Join(testFolder, "linked-file.txt")
|
||||
err = os.Symlink(testFile, linkedFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Service instance
|
||||
svc := mockService()
|
||||
|
||||
// Helper to inject user into context
|
||||
injectUser := func(ctx context.Context, u *user.User) context.Context {
|
||||
return authn.SetInfo(ctx, u)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "Stat file directory",
|
||||
path: testFile,
|
||||
},
|
||||
{
|
||||
name: "Stat symlink to file",
|
||||
path: linkedFile,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := injectUser(t.Context(), u)
|
||||
req := connect.NewRequest(&filesystem.StatRequest{
|
||||
Path: tt.path,
|
||||
})
|
||||
resp, err := svc.Stat(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, resp.Msg)
|
||||
require.NotNil(t, resp.Msg.GetEntry())
|
||||
assert.Equal(t, tt.path, resp.Msg.GetEntry().GetPath())
|
||||
assert.Equal(t, filesystem.FileType_FILE_TYPE_FILE, resp.Msg.GetEntry().GetType())
|
||||
assert.Equal(t, u.Username, resp.Msg.GetEntry().GetOwner())
|
||||
assert.Equal(t, group.Name, resp.Msg.GetEntry().GetGroup())
|
||||
assert.Equal(t, uint32(0o644), resp.Msg.GetEntry().GetMode())
|
||||
if tt.path == linkedFile {
|
||||
require.NotNil(t, resp.Msg.GetEntry().GetSymlinkTarget())
|
||||
assert.Equal(t, testFile, resp.Msg.GetEntry().GetSymlinkTarget())
|
||||
} else {
|
||||
assert.Empty(t, resp.Msg.GetEntry().GetSymlinkTarget())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatMissingPathReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := mockService()
|
||||
ctx := authn.SetInfo(t.Context(), u)
|
||||
|
||||
req := connect.NewRequest(&filesystem.StatRequest{
|
||||
Path: filepath.Join(t.TempDir(), "missing.txt"),
|
||||
})
|
||||
|
||||
_, err = svc.Stat(ctx, req)
|
||||
require.Error(t, err)
|
||||
|
||||
var connectErr *connect.Error
|
||||
require.ErrorAs(t, err, &connectErr)
|
||||
assert.Equal(t, connect.CodeNotFound, connectErr.Code())
|
||||
}
|
||||
107
envd/internal/services/filesystem/utils.go
Normal file
107
envd/internal/services/filesystem/utils.go
Normal file
@ -0,0 +1,107 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem"
|
||||
)
|
||||
|
||||
// Filesystem magic numbers from Linux kernel (include/uapi/linux/magic.h)
|
||||
const (
|
||||
nfsSuperMagic = 0x6969
|
||||
cifsMagic = 0xFF534D42
|
||||
smbSuperMagic = 0x517B
|
||||
smb2MagicNumber = 0xFE534D42
|
||||
fuseSuperMagic = 0x65735546
|
||||
)
|
||||
|
||||
// IsPathOnNetworkMount checks if the given path is on a network filesystem mount.
|
||||
// Returns true if the path is on NFS, CIFS, SMB, or FUSE filesystem.
|
||||
func IsPathOnNetworkMount(path string) (bool, error) {
|
||||
var statfs syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &statfs); err != nil {
|
||||
return false, fmt.Errorf("failed to statfs %s: %w", path, err)
|
||||
}
|
||||
|
||||
switch statfs.Type {
|
||||
case nfsSuperMagic, cifsMagic, smbSuperMagic, smb2MagicNumber, fuseSuperMagic:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func entryInfo(path string) (*rpc.EntryInfo, error) {
|
||||
info, err := filesystem.GetEntryFromPath(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("file not found: %w", err))
|
||||
}
|
||||
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err))
|
||||
}
|
||||
|
||||
owner, group := getFileOwnership(info)
|
||||
|
||||
return &rpc.EntryInfo{
|
||||
Name: info.Name,
|
||||
Type: getEntryType(info.Type),
|
||||
Path: info.Path,
|
||||
Size: info.Size,
|
||||
Mode: uint32(info.Mode),
|
||||
Permissions: info.Permissions,
|
||||
Owner: owner,
|
||||
Group: group,
|
||||
ModifiedTime: toTimestamp(info.ModifiedTime),
|
||||
SymlinkTarget: info.SymlinkTarget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toTimestamp(time time.Time) *timestamppb.Timestamp {
|
||||
if time.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return timestamppb.New(time)
|
||||
}
|
||||
|
||||
// getFileOwnership returns the owner and group names for a file.
|
||||
// If the lookup fails, it returns the numeric UID and GID as strings.
|
||||
func getFileOwnership(fileInfo filesystem.EntryInfo) (owner, group string) {
|
||||
// Look up username
|
||||
owner = fmt.Sprintf("%d", fileInfo.UID)
|
||||
if u, err := user.LookupId(owner); err == nil {
|
||||
owner = u.Username
|
||||
}
|
||||
|
||||
// Look up group name
|
||||
group = fmt.Sprintf("%d", fileInfo.GID)
|
||||
if g, err := user.LookupGroupId(group); err == nil {
|
||||
group = g.Name
|
||||
}
|
||||
|
||||
return owner, group
|
||||
}
|
||||
|
||||
// getEntryType determines the type of file entry based on its mode and path.
|
||||
// If the file is a symlink, it follows the symlink to determine the actual type.
|
||||
func getEntryType(fileType filesystem.FileType) rpc.FileType {
|
||||
switch fileType {
|
||||
case filesystem.FileFileType:
|
||||
return rpc.FileType_FILE_TYPE_FILE
|
||||
case filesystem.DirectoryFileType:
|
||||
return rpc.FileType_FILE_TYPE_DIRECTORY
|
||||
case filesystem.SymlinkFileType:
|
||||
return rpc.FileType_FILE_TYPE_SYMLINK
|
||||
default:
|
||||
return rpc.FileType_FILE_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
149
envd/internal/services/filesystem/utils_test.go
Normal file
149
envd/internal/services/filesystem/utils_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
osuser "os/user"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fsmodel "git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem"
|
||||
)
|
||||
|
||||
func TestIsPathOnNetworkMount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test with a regular directory (should not be on network mount)
|
||||
tempDir := t.TempDir()
|
||||
isNetwork, err := IsPathOnNetworkMount(tempDir)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isNetwork, "temp directory should not be on a network mount")
|
||||
}
|
||||
|
||||
func TestIsPathOnNetworkMount_FuseMount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Require bindfs to be available
|
||||
_, err := exec.LookPath("bindfs")
|
||||
require.NoError(t, err, "bindfs must be installed for this test")
|
||||
|
||||
// Require fusermount to be available (needed for unmounting)
|
||||
_, err = exec.LookPath("fusermount")
|
||||
require.NoError(t, err, "fusermount must be installed for this test")
|
||||
|
||||
// Create source and mount directories
|
||||
sourceDir := t.TempDir()
|
||||
mountDir := t.TempDir()
|
||||
|
||||
// Mount sourceDir onto mountDir using bindfs (FUSE)
|
||||
ctx := context.Background()
|
||||
cmd := exec.CommandContext(ctx, "bindfs", sourceDir, mountDir)
|
||||
require.NoError(t, cmd.Run(), "failed to mount bindfs")
|
||||
|
||||
// Ensure we unmount on cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = exec.CommandContext(context.Background(), "fusermount", "-u", mountDir).Run()
|
||||
})
|
||||
|
||||
// Test that the FUSE mount is detected
|
||||
isNetwork, err := IsPathOnNetworkMount(mountDir)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isNetwork, "FUSE mount should be detected as network filesystem")
|
||||
|
||||
// Test that the source directory is NOT detected as network mount
|
||||
isNetworkSource, err := IsPathOnNetworkMount(sourceDir)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isNetworkSource, "source directory should not be detected as network filesystem")
|
||||
}
|
||||
|
||||
func TestGetFileOwnership_CurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("current user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Get current user running the tests
|
||||
cur, err := osuser.Current()
|
||||
if err != nil {
|
||||
t.Skipf("unable to determine current user: %v", err)
|
||||
}
|
||||
|
||||
// Determine expected owner/group using the same lookup logic
|
||||
expectedOwner := cur.Uid
|
||||
if u, err := osuser.LookupId(cur.Uid); err == nil {
|
||||
expectedOwner = u.Username
|
||||
}
|
||||
|
||||
expectedGroup := cur.Gid
|
||||
if g, err := osuser.LookupGroupId(cur.Gid); err == nil {
|
||||
expectedGroup = g.Name
|
||||
}
|
||||
|
||||
// Parse UID/GID strings to uint32 for EntryInfo
|
||||
uid64, err := strconv.ParseUint(cur.Uid, 10, 32)
|
||||
require.NoError(t, err)
|
||||
gid64, err := strconv.ParseUint(cur.Gid, 10, 32)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build a minimal EntryInfo with current UID/GID
|
||||
info := fsmodel.EntryInfo{ // from shared pkg
|
||||
UID: uint32(uid64),
|
||||
GID: uint32(gid64),
|
||||
}
|
||||
|
||||
owner, group := getFileOwnership(info)
|
||||
assert.Equal(t, expectedOwner, owner)
|
||||
assert.Equal(t, expectedGroup, group)
|
||||
})
|
||||
|
||||
t.Run("no user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Find a UID that does not exist on this system
|
||||
var unknownUIDStr string
|
||||
for i := 60001; i < 70000; i++ { // search a high range typically unused
|
||||
idStr := strconv.Itoa(i)
|
||||
if _, err := osuser.LookupId(idStr); err != nil {
|
||||
unknownUIDStr = idStr
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if unknownUIDStr == "" {
|
||||
t.Skip("could not find a non-existent UID in the probed range")
|
||||
}
|
||||
|
||||
// Find a GID that does not exist on this system
|
||||
var unknownGIDStr string
|
||||
for i := 60001; i < 70000; i++ { // search a high range typically unused
|
||||
idStr := strconv.Itoa(i)
|
||||
if _, err := osuser.LookupGroupId(idStr); err != nil {
|
||||
unknownGIDStr = idStr
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if unknownGIDStr == "" {
|
||||
t.Skip("could not find a non-existent GID in the probed range")
|
||||
}
|
||||
|
||||
// Parse to uint32 for EntryInfo construction
|
||||
uid64, err := strconv.ParseUint(unknownUIDStr, 10, 32)
|
||||
require.NoError(t, err)
|
||||
gid64, err := strconv.ParseUint(unknownGIDStr, 10, 32)
|
||||
require.NoError(t, err)
|
||||
|
||||
info := fsmodel.EntryInfo{
|
||||
UID: uint32(uid64),
|
||||
GID: uint32(gid64),
|
||||
}
|
||||
|
||||
owner, group := getFileOwnership(info)
|
||||
// Expect numeric fallbacks because lookups should fail for unknown IDs
|
||||
assert.Equal(t, unknownUIDStr, owner)
|
||||
assert.Equal(t, unknownGIDStr, group)
|
||||
})
|
||||
}
|
||||
159
envd/internal/services/filesystem/watch.go
Normal file
159
envd/internal/services/filesystem/watch.go
Normal file
@ -0,0 +1,159 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/e2b-dev/fsnotify"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/logs"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
|
||||
)
|
||||
|
||||
func (s Service) WatchDir(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error {
|
||||
return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.watchHandler)
|
||||
}
|
||||
|
||||
func (s Service) watchHandler(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error {
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(watchPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err))
|
||||
}
|
||||
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err))
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err))
|
||||
}
|
||||
|
||||
// Check if path is on a network filesystem mount
|
||||
isNetworkMount, err := IsPathOnNetworkMount(watchPath)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err))
|
||||
}
|
||||
if isNetworkMount {
|
||||
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath))
|
||||
}
|
||||
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err))
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
err = w.Add(utils.FsnotifyPath(watchPath, req.Msg.GetRecursive()))
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err))
|
||||
}
|
||||
|
||||
err = stream.Send(&rpc.WatchDirResponse{
|
||||
Event: &rpc.WatchDirResponse_Start{
|
||||
Start: &rpc.WatchDirResponse_StartEvent{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", err))
|
||||
}
|
||||
|
||||
keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req)
|
||||
defer keepaliveTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-keepaliveTicker.C:
|
||||
streamErr := stream.Send(&rpc.WatchDirResponse{
|
||||
Event: &rpc.WatchDirResponse_Keepalive{
|
||||
Keepalive: &rpc.WatchDirResponse_KeepAlive{},
|
||||
},
|
||||
})
|
||||
if streamErr != nil {
|
||||
return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr))
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case chErr, ok := <-w.Errors:
|
||||
if !ok {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed"))
|
||||
}
|
||||
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr))
|
||||
case e, ok := <-w.Events:
|
||||
if !ok {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed"))
|
||||
}
|
||||
|
||||
// One event can have multiple operations.
|
||||
ops := []rpc.EventType{}
|
||||
|
||||
if fsnotify.Create.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE)
|
||||
}
|
||||
|
||||
if fsnotify.Rename.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME)
|
||||
}
|
||||
|
||||
if fsnotify.Chmod.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD)
|
||||
}
|
||||
|
||||
if fsnotify.Write.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE)
|
||||
}
|
||||
|
||||
if fsnotify.Remove.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE)
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
name, nameErr := filepath.Rel(watchPath, e.Name)
|
||||
if nameErr != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr))
|
||||
}
|
||||
|
||||
filesystemEvent := &rpc.WatchDirResponse_Filesystem{
|
||||
Filesystem: &rpc.FilesystemEvent{
|
||||
Name: name,
|
||||
Type: op,
|
||||
},
|
||||
}
|
||||
|
||||
event := &rpc.WatchDirResponse{
|
||||
Event: filesystemEvent,
|
||||
}
|
||||
|
||||
streamErr := stream.Send(event)
|
||||
|
||||
s.logger.
|
||||
Debug().
|
||||
Str("event_type", "filesystem_event").
|
||||
Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)).
|
||||
Interface("filesystem_event", event).
|
||||
Msg("Streaming filesystem event")
|
||||
|
||||
if streamErr != nil {
|
||||
return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending filesystem event: %w", streamErr))
|
||||
}
|
||||
|
||||
resetKeepalive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
224
envd/internal/services/filesystem/watch_sync.go
Normal file
224
envd/internal/services/filesystem/watch_sync.go
Normal file
@ -0,0 +1,224 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/e2b-dev/fsnotify"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/logs"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
|
||||
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
|
||||
"git.omukk.dev/wrenn/sandbox/envd/internal/shared/id"
|
||||
)
|
||||
|
||||
type FileWatcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
Events []*rpc.FilesystemEvent
|
||||
cancel func()
|
||||
Error error
|
||||
|
||||
Lock sync.Mutex
|
||||
}
|
||||
|
||||
func CreateFileWatcher(ctx context.Context, watchPath string, recursive bool, operationID string, logger *zerolog.Logger) (*FileWatcher, error) {
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err))
|
||||
}
|
||||
|
||||
// We don't want to cancel the context when the request is finished
|
||||
ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
|
||||
err = w.Add(utils.FsnotifyPath(watchPath, recursive))
|
||||
if err != nil {
|
||||
_ = w.Close()
|
||||
cancel()
|
||||
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err))
|
||||
}
|
||||
fw := &FileWatcher{
|
||||
watcher: w,
|
||||
cancel: cancel,
|
||||
Events: []*rpc.FilesystemEvent{},
|
||||
Error: nil,
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case chErr, ok := <-w.Errors:
|
||||
if !ok {
|
||||
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr))
|
||||
|
||||
return
|
||||
case e, ok := <-w.Events:
|
||||
if !ok {
|
||||
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// One event can have multiple operations.
|
||||
ops := []rpc.EventType{}
|
||||
|
||||
if fsnotify.Create.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE)
|
||||
}
|
||||
|
||||
if fsnotify.Rename.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME)
|
||||
}
|
||||
|
||||
if fsnotify.Chmod.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD)
|
||||
}
|
||||
|
||||
if fsnotify.Write.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE)
|
||||
}
|
||||
|
||||
if fsnotify.Remove.Has(e.Op) {
|
||||
ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE)
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
name, nameErr := filepath.Rel(watchPath, e.Name)
|
||||
if nameErr != nil {
|
||||
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fw.Lock.Lock()
|
||||
fw.Events = append(fw.Events, &rpc.FilesystemEvent{
|
||||
Name: name,
|
||||
Type: op,
|
||||
})
|
||||
fw.Lock.Unlock()
|
||||
|
||||
// these are only used for logging
|
||||
filesystemEvent := &rpc.WatchDirResponse_Filesystem{
|
||||
Filesystem: &rpc.FilesystemEvent{
|
||||
Name: name,
|
||||
Type: op,
|
||||
},
|
||||
}
|
||||
event := &rpc.WatchDirResponse{
|
||||
Event: filesystemEvent,
|
||||
}
|
||||
|
||||
logger.
|
||||
Debug().
|
||||
Str("event_type", "filesystem_event").
|
||||
Str(string(logs.OperationIDKey), operationID).
|
||||
Interface("filesystem_event", event).
|
||||
Msg("Streaming filesystem event")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return fw, nil
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) Close() {
|
||||
_ = fw.watcher.Close()
|
||||
fw.cancel()
|
||||
}
|
||||
|
||||
func (s Service) CreateWatcher(ctx context.Context, req *connect.Request[rpc.CreateWatcherRequest]) (*connect.Response[rpc.CreateWatcherResponse], error) {
|
||||
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(watchPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err))
|
||||
}
|
||||
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err))
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err))
|
||||
}
|
||||
|
||||
// Check if path is on a network filesystem mount
|
||||
isNetworkMount, err := IsPathOnNetworkMount(watchPath)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err))
|
||||
}
|
||||
if isNetworkMount {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath))
|
||||
}
|
||||
|
||||
watcherId := "w" + id.Generate()
|
||||
|
||||
w, err := CreateFileWatcher(ctx, watchPath, req.Msg.GetRecursive(), watcherId, s.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.watchers.Store(watcherId, w)
|
||||
|
||||
return connect.NewResponse(&rpc.CreateWatcherResponse{
|
||||
WatcherId: watcherId,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s Service) GetWatcherEvents(_ context.Context, req *connect.Request[rpc.GetWatcherEventsRequest]) (*connect.Response[rpc.GetWatcherEventsResponse], error) {
|
||||
watcherId := req.Msg.GetWatcherId()
|
||||
|
||||
w, ok := s.watchers.Load(watcherId)
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId))
|
||||
}
|
||||
|
||||
if w.Error != nil {
|
||||
return nil, w.Error
|
||||
}
|
||||
|
||||
w.Lock.Lock()
|
||||
defer w.Lock.Unlock()
|
||||
events := w.Events
|
||||
w.Events = []*rpc.FilesystemEvent{}
|
||||
|
||||
return connect.NewResponse(&rpc.GetWatcherEventsResponse{
|
||||
Events: events,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s Service) RemoveWatcher(_ context.Context, req *connect.Request[rpc.RemoveWatcherRequest]) (*connect.Response[rpc.RemoveWatcherResponse], error) {
|
||||
watcherId := req.Msg.GetWatcherId()
|
||||
|
||||
w, ok := s.watchers.Load(watcherId)
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId))
|
||||
}
|
||||
|
||||
w.Close()
|
||||
s.watchers.Delete(watcherId)
|
||||
|
||||
return connect.NewResponse(&rpc.RemoveWatcherResponse{}), nil
|
||||
}
|
||||
Reference in New Issue
Block a user