filed

Job queue using FUSE

git clone git://mccd.space/filed

commit 154a0a45320da2d87d2599a2c92e121bb49eb707
parent ebd1b1342664e2d3f08d47a07558c6b479282af0
Author: Marc Coquand <marc@coquand.email>
Date:   Mon, 15 Dec 2025 12:52:34 +0100

Access right support

Diffstat:
MREADME.md | 2+-
Mconfig.go | 46++++++++++++++++++++++++++++++++++++++++++----
Mmain.go | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mnewid.go | 37++++++++++++++++++++++++++++++++++---
Mqj.1.scd | 8+++++++-
Msetfattr.go | 4+---
Mstore/filemeta.go | 6+++---
7 files changed, 170 insertions(+), 34 deletions(-)
diff --git a/README.md b/README.md
@@ -71,7 +71,7 @@ $ cat /tmp/qj-jobs/active/1
 
 ## TODO
 
-- [ ] Support chmod and chown
+- [x] Support chmod and chown
 - [ ] State is configured via environment variable
 - [ ] Customizable backoff and timeout before retries
 - [ ] "Landlock"-mode, or sandboxed jobs
diff --git a/config.go b/config.go
@@ -18,11 +18,22 @@ type ConfigFile struct {
 	inode   uint64
 }
 
-func (f *ConfigFile) Attr(ctx context.Context, a *fuse.Attr) error {
+func (f ConfigFile) Attr(ctx context.Context, a *fuse.Attr) error {
+	fileMeta, err := f.manager.store.GetFileMeta(f.inode)
+	if err != nil {
+		slog.Error("Could not retrieve file metadata", "error", err)
+		return syscall.EIO
+	} else if fileMeta != nil {
+		a.Mode = os.FileMode(fileMeta.Mode)
+		a.Gid = fileMeta.GID
+		a.Uid = fileMeta.UID
+	} else {
+		a.Mode = 0o440
+		a.Gid = uint32(os.Getgid())
+		a.Uid = uint32(os.Getuid())
+	}
+
 	config := f.manager.store.GetConfig()
-	a.Mode = 0o740
-	a.Gid = uint32(os.Getgid())
-	a.Uid = uint32(os.Getuid())
 	a.Inode = f.inode
 	confJson, err := json.Marshal(config)
 	if err != nil {
@@ -33,6 +44,33 @@ func (f *ConfigFile) Attr(ctx context.Context, a *fuse.Attr) error {
 	a.Size = uint64(len(confJson))
 	return nil
 }
+func (f *ConfigFile) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
+	slog.Warn("FUSE: Changing access permissions")
+	defaultMode, err := f.manager.store.GetFileMeta(f.inode)
+	if err != nil {
+		return err
+	}
+	if defaultMode == nil {
+		defaultMode = &store.FileMeta{
+			Inode: f.inode,
+			Mode:  uint32(0o640),
+			UID:   uint32(os.Getgid()),
+			GID:   uint32(os.Getuid()),
+		}
+
+	}
+	config := f.manager.store.GetConfig()
+	confJson, err := json.Marshal(config)
+	if err != nil {
+		slog.Error("FUSE: Could not marshal conf", "error", err)
+		return nil
+	}
+
+	resp.Attr.Size = uint64(len(confJson))
+
+	return Setattr(f.manager.store, defaultMode, ctx, req, resp)
+}
+
 func (f *ConfigFile) ReadAll(ctx context.Context) (jsonConf []byte, err error) {
 	slog.Info("FUSE: Read file content")
 	config := f.manager.store.GetConfig()
diff --git a/main.go b/main.go
@@ -51,7 +51,6 @@ func main() {
 	c, err := fuse.Mount(
 		mountpoint,
 		fuse.FSName("qj"),
-		fuse.Subtype("qjfs"),
 		fuse.AllowOther(),
 		fuse.DefaultPermissions(),
 	)
@@ -90,10 +89,10 @@ func (RootDir) Attr(ctx context.Context, a *fuse.Attr) error {
 }
 
 func (rd RootDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
-	slog.Info("FUSE: Lookup", "name", name)
+	slog.Debug("FUSE: Lookup", "name", name)
 	switch name {
 	case store.StatePending:
-		return PendingDir{manager: rd.manager}, nil
+		return PendingDir{manager: rd.manager, inode: 2}, nil
 	case store.StateCompleted:
 		return JobDir{state: name, manager: rd.manager, inode: 3}, nil
 	case store.StateFailed:
@@ -124,18 +123,51 @@ func (RootDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
 
 type PendingDir struct {
 	manager *JobManager
+	inode   uint64
 }
 
-func (PendingDir) Attr(ctx context.Context, a *fuse.Attr) error {
-	a.Mode = os.ModeDir | 0o750
-	a.Gid = uint32(os.Getgid())
-	a.Uid = uint32(os.Getuid())
-	a.Inode = 2
+func (pd PendingDir) Attr(ctx context.Context, a *fuse.Attr) error {
+	fileMeta, err := pd.manager.store.GetFileMeta(pd.inode)
+	if err != nil {
+		slog.Error("Could not retrieve file metadata", "error", err)
+		return syscall.EIO
+	} else if fileMeta != nil {
+		a.Mode = os.FileMode(fileMeta.Mode)
+		a.Gid = fileMeta.GID
+		a.Uid = fileMeta.UID
+		a.Inode = pd.inode
+
+	} else {
+		a.Mode = os.ModeDir | 0o750
+		a.Gid = uint32(os.Getgid())
+		a.Uid = uint32(os.Getuid())
+		a.Inode = pd.inode
+	}
+	return nil
+}
+
+func (pd *PendingDir) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
+	slog.Warn("FUSE: Changing access permissions")
+	defaultMode, err := pd.manager.store.GetFileMeta(pd.inode)
+	if defaultMode == nil {
+		defaultMode = &store.FileMeta{
+			Inode: pd.inode,
+			Mode:  uint32(os.ModeDir | 0o750),
+			UID:   uint32(os.Getgid()),
+			GID:   uint32(os.Getuid()),
+		}
+
+	}
+	err = Setattr(pd.manager.store, defaultMode, ctx, req, resp)
+	if err != nil {
+		return err
+	}
+
 	return nil
 }
 
-func (jd PendingDir) ReadDirAll(ctx context.Context) (entries []fuse.Dirent, err error) {
-	jobs, err := jd.manager.store.ListJobsByState(store.StatePending)
+func (pd PendingDir) ReadDirAll(ctx context.Context) (entries []fuse.Dirent, err error) {
+	jobs, err := pd.manager.store.ListJobsByState(store.StatePending)
 	if err != nil {
 		slog.Error("FUSE: Could not find jobs", "error", err)
 		return entries, nil
@@ -145,27 +177,27 @@ func (jd PendingDir) ReadDirAll(ctx context.Context) (entries []fuse.Dirent, err
 	}
 	return entries, nil
 }
-func (d PendingDir) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) {
+func (pd PendingDir) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) {
 	slog.Info("Creating job file", "name", req.Name)
 
 	f := &JobCreationFile{
 		id:    req.Name,
-		store: d.manager.store,
+		store: pd.manager.store,
 		uid:   req.Uid,
 		gid:   req.Gid,
 		mode:  req.Mode,
 	}
 	return f, f, nil
 }
-func (jd PendingDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
+func (pd PendingDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
 	slog.Debug("FUSE: Lookup", "name", name)
-	job, err := jd.manager.store.GetJob(name)
+	job, err := pd.manager.store.GetJob(name)
 	if err != nil {
 		return nil, syscall.ENOENT
 	}
 	if job.State == store.StatePending {
 		slog.Debug("FUSE: Found job", "id", job.ID)
-		return &File{job, jd.manager}, nil
+		return &File{job, pd.manager}, nil
 	} else {
 		return nil, syscall.ENOENT
 	}
@@ -264,11 +296,42 @@ type JobDir struct {
 }
 
 func (jd JobDir) Attr(ctx context.Context, a *fuse.Attr) error {
-	a.Mode = os.ModeDir | 0o750
+	fileMeta, err := jd.manager.store.GetFileMeta(jd.inode)
+	if err != nil {
+		slog.Error("Could not retrieve file metadata", "error", err)
+		return syscall.EIO
+	} else if fileMeta != nil {
+		a.Mode = os.FileMode(fileMeta.Mode)
+		a.Gid = fileMeta.GID
+		a.Uid = fileMeta.UID
+		a.Inode = jd.inode
+
+	} else {
+		a.Mode = os.ModeDir | 0o750
+		a.Gid = uint32(os.Getgid())
+		a.Uid = uint32(os.Getuid())
+		a.Inode = jd.inode
+	}
+	return nil
+}
+
+func (jd JobDir) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
+	slog.Warn("FUSE: Changing access permissions")
+	defaultMode, err := jd.manager.store.GetFileMeta(jd.inode)
+	if defaultMode == nil {
+		defaultMode = &store.FileMeta{
+			Inode: jd.inode,
+			Mode:  uint32(os.ModeDir | 0o750),
+			UID:   uint32(os.Getgid()),
+			GID:   uint32(os.Getuid()),
+		}
+
+	}
+	err = Setattr(jd.manager.store, defaultMode, ctx, req, resp)
+	if err != nil {
+		return err
+	}
 
-	a.Gid = uint32(os.Getgid())
-	a.Uid = uint32(os.Getuid())
-	a.Inode = jd.inode
 	return nil
 }
 
diff --git a/newid.go b/newid.go
@@ -6,6 +6,7 @@ import (
 	"math/rand"
 	"os"
 	"qj/store"
+	"syscall"
 	"time"
 
 	"bazil.org/fuse"
@@ -36,13 +37,43 @@ type NewIdFile struct {
 }
 
 func (f NewIdFile) Attr(ctx context.Context, a *fuse.Attr) error {
-	a.Mode = 0o440
-	a.Gid = uint32(os.Getgid())
-	a.Uid = uint32(os.Getuid())
+	fileMeta, err := f.manager.store.GetFileMeta(f.inode)
+	if err != nil {
+		slog.Error("Could not retrieve file metadata", "error", err)
+		return syscall.EIO
+	} else if fileMeta != nil {
+		a.Mode = os.FileMode(fileMeta.Mode)
+		a.Gid = fileMeta.GID
+		a.Uid = fileMeta.UID
+	} else {
+		a.Mode = 0o440
+		a.Gid = uint32(os.Getgid())
+		a.Uid = uint32(os.Getuid())
+	}
 	a.Inode = f.inode
 	a.Size = uint64(4)
+
 	return nil
 }
+func (f *NewIdFile) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
+	slog.Warn("FUSE: Changing access permissions")
+	defaultMode, err := f.manager.store.GetFileMeta(f.inode)
+	if err != nil {
+		return err
+	}
+	if defaultMode == nil {
+		defaultMode = &store.FileMeta{
+			Inode: f.inode,
+			Mode:  uint32(0o440),
+			UID:   uint32(os.Getgid()),
+			GID:   uint32(os.Getuid()),
+		}
+
+	}
+	resp.Attr.Size = uint64(4)
+	return Setattr(f.manager.store, defaultMode, ctx, req, resp)
+}
+
 func (f *NewIdFile) ReadAll(ctx context.Context) ([]byte, error) {
 	slog.Info("FUSE: Read file content")
 	return randomJobId(f.manager.store, 4), nil
diff --git a/qj.1.scd b/qj.1.scd
@@ -45,7 +45,13 @@ By default, all write operations are allowed by the executing user, and all
 read operations by the executing user's group. All other users have zero 
 access to the system.
 
-(TODO) The access rights can be modified using _CHOWN(1)_ and _CHMOD(1)_.
+It is recommended to that the admin updates the users and group to apply
+principle of least access. Importantly, the system is intended for only
+trusted scripts: the job user has access to the state, and is thus able to
+rewrite access rights. However, users can use _namespaces(7)_ or _Landlock(7)_
+to limit access rights of the running script.
+
+Access rights can be modified using _CHOWN(1)_ and _CHMOD(1)_. 
 
 # EXAMPLES
 
diff --git a/setfattr.go b/setfattr.go
@@ -10,7 +10,7 @@ import (
 	"bazil.org/fuse"
 )
 
-func Setattr(store *store.Store, meta *store.FileMeta, content []byte, inode uint64, ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
+func Setattr(store *store.Store, meta *store.FileMeta, ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
 	if req.Valid.Mode() {
 		meta.Mode = uint32(req.Mode)
 	}
@@ -32,7 +32,5 @@ func Setattr(store *store.Store, meta *store.FileMeta, content []byte, inode uin
 	resp.Attr.Mode = os.FileMode(meta.Mode)
 	resp.Attr.Uid = meta.UID
 	resp.Attr.Gid = meta.GID
-	resp.Attr.Size = uint64(len(content))
-
 	return nil
 }
diff --git a/store/filemeta.go b/store/filemeta.go
@@ -12,14 +12,14 @@ type FileMeta struct {
 	GID   uint32
 }
 
-// Returns file metadata, if it exists.
-func (st *Store) GetFileMeta(inode uint64) (m *FileMeta, err error) {
+func (st *Store) GetFileMeta(inode uint64) (*FileMeta, error) {
 	query := `
 		SELECT inode, mode, uid, gid 
 		FROM file_meta 
 		WHERE inode = ?
 	`
-	err = st.db.QueryRow(query, inode).Scan(&m.Inode, &m.Mode, &m.UID, &m.GID)
+	m := &FileMeta{}
+	err := st.db.QueryRow(query, inode).Scan(&m.Inode, &m.Mode, &m.UID, &m.GID)
 	if err == sql.ErrNoRows {
 		// No metadata exists, that's ok
 		return nil, nil