From 24821b0ebe35c52c9f4c96ae7bd5bb98dac3b643 Mon Sep 17 00:00:00 2001 From: riaarora-boop Date: Thu, 9 Jul 2026 15:40:14 +0530 Subject: [PATCH] meta: add Af2Desc metadata storer for CDM SDFS AF2 DESC attribute Implements Af2Desc, a MetadataStorer that packs must-store S3 object metadata (etag, content-type, user metadata) into the single AF2 DESC file attribute (sdfs_af2_file_desc xattr) so it rides the AF2 partition snapshot. Serves reads from a write-through in-process cache because SDFS cannot read xattrs back after write. Exposes --af2-desc and --af2-desc-max-bytes flags on the posix subcommand. Mutually exclusive with --sidecar and --nometa. Jira: CDM-557243 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/meta/af2desc.go | 349 +++++++++++++++++++++++++++++++++++ backend/meta/af2desc_test.go | 305 ++++++++++++++++++++++++++++++ cmd/versitygw/posix.go | 21 +++ 3 files changed, 675 insertions(+) create mode 100644 backend/meta/af2desc.go create mode 100644 backend/meta/af2desc_test.go diff --git a/backend/meta/af2desc.go b/backend/meta/af2desc.go new file mode 100644 index 000000000..37d36b1fa --- /dev/null +++ b/backend/meta/af2desc.go @@ -0,0 +1,349 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package meta + +import ( + "encoding/json" + "errors" + "hash/fnv" + "os" + "path/filepath" + "sync" + "syscall" + + "github.com/pkg/xattr" + "github.com/versity/versitygw/s3err" +) + +// Af2Desc is an Otter MetadataStorer that packs the must-store S3 object +// metadata into the single AF2 "DESC" file attribute, so the metadata rides the +// AF2 partition snapshot together with the object data. +// +// On SDFS the only FUSE-writable attribute is "sdfs_af2_file_desc" -> the AF2 +// file_attributes["DESC"] slot; generic xattr keys are rejected and getxattr is +// unimplemented. Af2Desc therefore: +// - packs an allowlisted set of logical attributes into ONE JSON blob written +// under descXattrName (see descKeys); non-allowlisted keys are accepted and +// dropped so the blob stays within the MJF value-size cap; +// - serves read-back from an in-process write-through cache, because SDFS +// cannot read the attribute back. On a normal xattr-capable filesystem +// (APFS/ext4/tmpfs) the getxattr fallback works, which is what the unit +// tests exercise. On SDFS a cold cache (e.g. after restart) returns +// ErrNoSuchKey, which callers already treat as "empty"; restore-time +// read-back is the C++/Thrift af2GetPartitionMetadata path, out of scope +// for the gateway. +type Af2Desc struct { + // mu guards the structure of the cache map (entry insert/lookup/move). + mu sync.RWMutex + cache map[string]*descEntry // key: filepath.Join(bucket, object) + + // shards provides per-path locking so concurrent writes to different + // objects do not serialize behind one global mutex across the (blocking) + // setxattr syscall. Lock ordering: take the shard lock first, then acquire + // mu only briefly inside entry(); never the reverse. + shards []sync.Mutex + + maxLen int +} + +type descEntry struct { + // ino is the inode the cached map was last written against. A differing + // inode on a write means a new object replaced the old one (temp-fd + + // same-dir rename), so the cached map must be reset to avoid leaking the + // prior object's metadata into the new one. + ino uint64 + m map[string]string +} + +// descXattrName is the only FUSE-writable AF2 attribute on SDFS; SDFS maps it to +// file_attributes["DESC"], persisted at partition finalize. The SDFS FUSE +// handler matches this key VERBATIM and does NOT strip the "user." namespace +// (verified on-cluster 2026-06-17: setxattr "user.sdfs_af2_file_desc" -> ENOTSUP, +// raw "sdfs_af2_file_desc" -> OK). It must therefore be set WITHOUT the +// xattrPrefix that normal namespaced S3 attributes carry. +const descXattrName = "sdfs_af2_file_desc" + +// DefaultDescMaxBytes is the conservative MJF max_value_size for an AF2 file +// attribute. The packed JSON blob must not exceed it. +const DefaultDescMaxBytes = 256 + +// descShards is the number of per-path lock shards. +const descShards = 256 + +// descKeys is the allowlist of logical S3 attributes packed into the DESC blob. +// The values mirror the (unexported) backend/posix attribute-key constants. Any +// key outside this set is accepted-and-dropped on store and reported absent on +// retrieve, so only must-store object metadata occupies the size-capped blob. +var descKeys = map[string]bool{ + "etag": true, + "content-type": true, + "content-encoding": true, + "content-disposition": true, + "content-language": true, + "cache-control": true, + "expires": true, + "metadata": true, +} + +var _ MetadataStorer = (*Af2Desc)(nil) + +// NewAf2Desc creates an Af2Desc storer. maxValueBytes caps the packed blob; a +// non-positive value uses DefaultDescMaxBytes. +func NewAf2Desc(maxValueBytes int) *Af2Desc { + if maxValueBytes <= 0 { + maxValueBytes = DefaultDescMaxBytes + } + return &Af2Desc{ + cache: make(map[string]*descEntry), + shards: make([]sync.Mutex, descShards), + maxLen: maxValueBytes, + } +} + +func (a *Af2Desc) shardFor(path string) *sync.Mutex { + h := fnv.New32a() + // hash.Write never returns an error. + _, _ = h.Write([]byte(path)) + return &a.shards[h.Sum32()%descShards] +} + +// entry returns the cache entry for path, creating an empty one if absent. The +// caller must already hold the path's shard lock. +func (a *Af2Desc) entry(path string) *descEntry { + a.mu.RLock() + e := a.cache[path] + a.mu.RUnlock() + if e != nil { + return e + } + + a.mu.Lock() + defer a.mu.Unlock() + if e = a.cache[path]; e == nil { + e = &descEntry{m: map[string]string{}} + a.cache[path] = e + } + return e +} + +func inoOf(f *os.File) (uint64, bool) { + fi, err := f.Stat() + if err != nil { + return 0, false + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return uint64(st.Ino), true +} + +// readBlob reads and parses the on-disk DESC blob. Used as a fallback on cache +// miss; on SDFS getxattr is unimplemented (ENOTSUP/ENODATA) and this returns an +// error, which callers translate to ErrNoSuchKey. +func readBlob(f *os.File, path string) (map[string]string, error) { + var b []byte + var err error + if f != nil { + b, err = xattr.FGet(f, descXattrName) + } else { + b, err = xattr.Get(path, descXattrName) + } + if err != nil { + return nil, err + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +func mapXattrErr(err error) error { + if errors.Is(err, syscall.ENOSPC) { + return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice) + } + if errors.Is(err, syscall.EROFS) { + return s3err.GetAPIError(s3err.ErrMethodNotAllowed) + } + return err +} + +// StoreAttribute packs attribute=value into the object's DESC blob and writes it +// through to the xattr. Non-allowlisted keys are accepted and dropped. When a +// non-nil f refers to a new inode, the cached map is reset first so a prior +// object's metadata cannot leak into the new object. +func (a *Af2Desc) StoreAttribute(f *os.File, bucket, object, attribute string, value []byte) error { + if !descKeys[attribute] { + return nil + } + + path := filepath.Join(bucket, object) + sh := a.shardFor(path) + sh.Lock() + defer sh.Unlock() + + e := a.entry(path) + + if f != nil { + if ino, ok := inoOf(f); ok && e.ino != ino { + e.m = map[string]string{} + e.ino = ino + } + } + + prev, had := e.m[attribute] + e.m[attribute] = string(value) + + blob, err := json.Marshal(e.m) + if err != nil { + a.revert(e, attribute, prev, had) + return err + } + if len(blob) > a.maxLen { + a.revert(e, attribute, prev, had) + return s3err.GetAPIError(s3err.ErrMetadataTooLarge) + } + + if f != nil { + err = xattr.FSet(f, descXattrName, blob) + } else { + err = xattr.Set(path, descXattrName, blob) + } + if err != nil { + a.revert(e, attribute, prev, had) + return mapXattrErr(err) + } + return nil +} + +// revert restores a single key to its prior state after a failed write, keeping +// the cache consistent with what is actually on disk. +func (a *Af2Desc) revert(e *descEntry, attribute, prev string, had bool) { + if had { + e.m[attribute] = prev + } else { + delete(e.m, attribute) + } +} + +// RetrieveAttribute returns the value of attribute from the object's DESC blob. +// It serves from the write-through cache; only when nothing is cached for the +// path does it consult the on-disk blob (which works on a normal xattr fs and +// fails to ErrNoSuchKey on SDFS). +func (a *Af2Desc) RetrieveAttribute(f *os.File, bucket, object, attribute string) ([]byte, error) { + if !descKeys[attribute] { + return nil, ErrNoSuchKey + } + + path := filepath.Join(bucket, object) + sh := a.shardFor(path) + sh.Lock() + defer sh.Unlock() + + e := a.entry(path) + if v, ok := e.m[attribute]; ok { + return []byte(v), nil + } + + // A non-empty cache entry is authoritative (we either wrote it or already + // read the full on-disk blob), so a missing key means ErrNoSuchKey. Only a + // cold (empty) entry falls back to disk. + if len(e.m) == 0 { + if m, err := readBlob(f, path); err == nil { + e.m = m + if v, ok := m[attribute]; ok { + return []byte(v), nil + } + } + } + return nil, ErrNoSuchKey +} + +// DeleteAttribute removes a single attribute from the object's DESC blob. +func (a *Af2Desc) DeleteAttribute(bucket, object, attribute string) error { + path := filepath.Join(bucket, object) + sh := a.shardFor(path) + sh.Lock() + defer sh.Unlock() + + e := a.entry(path) + delete(e.m, attribute) + + var err error + if len(e.m) == 0 { + err = xattr.Remove(path, descXattrName) + } else { + var blob []byte + blob, err = json.Marshal(e.m) + if err == nil { + err = xattr.Set(path, descXattrName, blob) + } + } + if errors.Is(err, xattr.ENOATTR) || errors.Is(err, syscall.ENOTSUP) { + return nil + } + if err != nil { + return mapXattrErr(err) + } + return nil +} + +// DeleteAttributes removes all metadata for the object (the whole DESC blob). +func (a *Af2Desc) DeleteAttributes(bucket, object string) error { + path := filepath.Join(bucket, object) + sh := a.shardFor(path) + sh.Lock() + defer sh.Unlock() + + a.mu.Lock() + delete(a.cache, path) + a.mu.Unlock() + + err := xattr.Remove(path, descXattrName) + if errors.Is(err, xattr.ENOATTR) || errors.Is(err, syscall.ENOTSUP) { + return nil + } + return mapXattrErr(err) +} + +// ListAttributes deliberately returns no keys. The DESC blob holds only +// must-store object metadata; the backend's ListAttributes consumers are the +// legacy X-Amz-Meta migration/cleanup paths (loadObjectMetadata, +// storeObjectMetadata), which must not see live DESC keys, and SDFS listxattr is +// unsupported. +// +// NOTE: returning an empty list also forecloses the general-object versioning +// attribute-copy path (createObjVersion); this is acceptable only because +// versioning is disabled in the Otter WAL deployment. A future general-object +// deployment must revisit this. +func (a *Af2Desc) ListAttributes(bucket, object string) ([]string, error) { + return []string{}, nil +} + +// RenameObject moves the cached entry from oldObject to newObject. The DESC +// xattr itself follows the inode across a rename, so there is no filesystem op. +func (a *Af2Desc) RenameObject(bucket, oldObject, newObject string) error { + oldPath := filepath.Join(bucket, oldObject) + newPath := filepath.Join(bucket, newObject) + + a.mu.Lock() + if e, ok := a.cache[oldPath]; ok { + a.cache[newPath] = e + delete(a.cache, oldPath) + } + a.mu.Unlock() + return nil +} diff --git a/backend/meta/af2desc_test.go b/backend/meta/af2desc_test.go new file mode 100644 index 000000000..4fa92b332 --- /dev/null +++ b/backend/meta/af2desc_test.go @@ -0,0 +1,305 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package meta + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/pkg/xattr" + "github.com/versity/versitygw/s3err" +) + +// These tests run on a normal xattr-capable filesystem (APFS/ext4/tmpfs), where +// getxattr works. That lets a fresh Af2Desc instance read back what a previous +// one wrote, exercising the real on-disk DESC blob in addition to the cache. On +// SDFS getxattr is unimplemented, so only the write-through cache serves reads; +// that path is covered by the cache-hit assertions here. + +func mustCreate(t *testing.T, path string) *os.File { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func rawBlob(t *testing.T, path string) map[string]string { + t.Helper() + b, err := xattr.Get(path, descXattrName) + if err != nil { + t.Fatalf("read raw DESC xattr on %s: %v", path, err) + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal DESC blob: %v", err) + } + return m +} + +func retr(t *testing.T, s *Af2Desc, bucket, object, attr string) (string, error) { + t.Helper() + b, err := s.RetrieveAttribute(nil, bucket, object, attr) + return string(b), err +} + +// TestAf2DescRoundTrip: multiple allowlisted attributes pack into ONE DESC blob +// and round-trip both from the cache and from disk via a fresh instance. +func TestAf2DescRoundTrip(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "obj1") + f := mustCreate(t, p) + + s := NewAf2Desc(0) + for k, v := range map[string]string{ + "etag": `"abc123"`, + "content-type": "application/octet-stream", + "metadata": `{"x-amz-meta-db":"pg"}`, + } { + if err := s.StoreAttribute(f, dir, "obj1", k, []byte(v)); err != nil { + t.Fatalf("store %s: %v", k, err) + } + } + + // Cache read-back. + if got, err := retr(t, s, dir, "obj1", "etag"); err != nil || got != `"abc123"` { + t.Fatalf("cache etag = %q, %v", got, err) + } + + // One physical xattr holds all three logical keys. + if m := rawBlob(t, p); len(m) != 3 || m["content-type"] != "application/octet-stream" { + t.Fatalf("on-disk blob = %v (want 3 keys)", m) + } + + // Fresh instance: read-back must come from the on-disk blob (getxattr). + s2 := NewAf2Desc(0) + if got, err := retr(t, s2, dir, "obj1", "metadata"); err != nil || got != `{"x-amz-meta-db":"pg"}` { + t.Fatalf("disk metadata = %q, %v", got, err) + } +} + +// TestAf2DescCrossCallFdNil: stored with a non-nil fd, retrieved with nil fd at +// the same path — proves the cache is keyed by path, not by fd (mirrors PUT->GET). +func TestAf2DescCrossCallFdNil(t *testing.T) { + dir := t.TempDir() + f := mustCreate(t, filepath.Join(dir, "k")) + s := NewAf2Desc(0) + + if err := s.StoreAttribute(f, dir, "k", "etag", []byte("E1")); err != nil { + t.Fatalf("store: %v", err) + } + if got, err := retr(t, s, dir, "k", "etag"); err != nil || got != "E1" { + t.Fatalf("nil-fd retrieve = %q, %v", got, err) + } +} + +// TestAf2DescOverwriteNewInode: re-creating the object (new inode) must not leak +// the prior object's metadata into the new blob (FV2). +func TestAf2DescOverwriteNewInode(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "seg1") + s := NewAf2Desc(0) + + // v1: full metadata set. + f1 := mustCreate(t, p) + for k, v := range map[string]string{"etag": "E1", "content-type": "A", "metadata": `{"a":"1"}`} { + if err := s.StoreAttribute(f1, dir, "seg1", k, []byte(v)); err != nil { + t.Fatalf("v1 store %s: %v", k, err) + } + } + _ = f1.Close() + + // Overwrite: new inode at the same path, only etag set. + if err := os.Remove(p); err != nil { + t.Fatalf("remove: %v", err) + } + f2 := mustCreate(t, p) + if err := s.StoreAttribute(f2, dir, "seg1", "etag", []byte("E2")); err != nil { + t.Fatalf("v2 store: %v", err) + } + + if got, err := retr(t, s, dir, "seg1", "etag"); err != nil || got != "E2" { + t.Fatalf("etag = %q, %v (want E2)", got, err) + } + for _, leaked := range []string{"content-type", "metadata"} { + if _, err := retr(t, s, dir, "seg1", leaked); !errors.Is(err, ErrNoSuchKey) { + t.Fatalf("%s leaked from v1: err=%v (want ErrNoSuchKey)", leaked, err) + } + } + // On-disk blob also reflects only v2. + if m := rawBlob(t, p); len(m) != 1 || m["etag"] != "E2" { + t.Fatalf("on-disk blob after overwrite = %v (want only etag=E2)", m) + } +} + +// TestAf2DescDropNonAllowlisted: a non-allowlisted key is accepted-and-dropped +// on store and reported absent on retrieve; no xattr is written for it. +func TestAf2DescDropNonAllowlisted(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "o") + f := mustCreate(t, p) + s := NewAf2Desc(0) + + if err := s.StoreAttribute(f, dir, "o", "checksums", []byte(`{"Type":"FULL_OBJECT"}`)); err != nil { + t.Fatalf("store checksums should no-op, got: %v", err) + } + if _, err := retr(t, s, dir, "o", "checksums"); !errors.Is(err, ErrNoSuchKey) { + t.Fatalf("checksums retrieve err=%v (want ErrNoSuchKey)", err) + } + // Nothing was written to disk. + if _, err := xattr.Get(p, descXattrName); err == nil { + t.Fatalf("DESC xattr exists but only a dropped key was stored") + } +} + +// TestAf2DescOverCap: a write that would exceed maxLen is rejected and prior +// state is preserved. +func TestAf2DescOverCap(t *testing.T) { + dir := t.TempDir() + f := mustCreate(t, filepath.Join(dir, "o")) + s := NewAf2Desc(20) // tiny cap + + // First small key fits: {"etag":"abc"} == 14 bytes. + if err := s.StoreAttribute(f, dir, "o", "etag", []byte("abc")); err != nil { + t.Fatalf("store etag: %v", err) + } + // Adding content-type blows the 20-byte cap. + err := s.StoreAttribute(f, dir, "o", "content-type", []byte("application/octet-stream")) + if err == nil { + t.Fatalf("over-cap store should fail") + } + if err != s3err.GetAPIError(s3err.ErrMetadataTooLarge) { + t.Fatalf("over-cap error = %v (want MetadataTooLarge)", err) + } + // The prior key survives; the rejected key is absent. + if got, e := retr(t, s, dir, "o", "etag"); e != nil || got != "abc" { + t.Fatalf("etag after over-cap = %q, %v (want abc)", got, e) + } + if _, e := retr(t, s, dir, "o", "content-type"); !errors.Is(e, ErrNoSuchKey) { + t.Fatalf("content-type after over-cap err=%v (want ErrNoSuchKey)", e) + } +} + +// TestAf2DescNilFdWrite: f==nil writes (path-based) round-trip, including the +// bucket-level case (object == ""). +func TestAf2DescNilFdWrite(t *testing.T) { + dir := t.TempDir() + s := NewAf2Desc(0) + + // Object write with nil fd. + p := filepath.Join(dir, "o") + mustCreate(t, p) + if err := s.StoreAttribute(nil, dir, "o", "content-type", []byte("text/plain")); err != nil { + t.Fatalf("nil-fd object store: %v", err) + } + s2 := NewAf2Desc(0) + if got, err := retr(t, s2, dir, "o", "content-type"); err != nil || got != "text/plain" { + t.Fatalf("disk content-type = %q, %v", got, err) + } + + // Bucket-level write (object == "") targets the bucket dir inode. + if err := s.StoreAttribute(nil, dir, "", "etag", []byte("B")); err != nil { + t.Fatalf("bucket-level store: %v", err) + } + if got, err := retr(t, s, dir, "", "etag"); err != nil || got != "B" { + t.Fatalf("bucket etag = %q, %v", got, err) + } +} + +// TestAf2DescDelete: single-key delete preserves siblings; DeleteAttributes +// clears everything (cache and disk). +func TestAf2DescDelete(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "o") + f := mustCreate(t, p) + s := NewAf2Desc(0) + _ = s.StoreAttribute(f, dir, "o", "etag", []byte("E")) + _ = s.StoreAttribute(f, dir, "o", "content-type", []byte("text/plain")) + + if err := s.DeleteAttribute(dir, "o", "content-type"); err != nil { + t.Fatalf("delete content-type: %v", err) + } + if _, err := retr(t, s, dir, "o", "content-type"); !errors.Is(err, ErrNoSuchKey) { + t.Fatalf("content-type after delete err=%v", err) + } + if got, err := retr(t, s, dir, "o", "etag"); err != nil || got != "E" { + t.Fatalf("etag after sibling delete = %q, %v", got, err) + } + + if err := s.DeleteAttributes(dir, "o"); err != nil { + t.Fatalf("delete attributes: %v", err) + } + if _, err := xattr.Get(p, descXattrName); err == nil { + t.Fatalf("DESC xattr still present after DeleteAttributes") + } +} + +// TestAf2DescListAttributesEmpty: ListAttributes returns no keys by design. +func TestAf2DescListAttributesEmpty(t *testing.T) { + dir := t.TempDir() + f := mustCreate(t, filepath.Join(dir, "o")) + s := NewAf2Desc(0) + _ = s.StoreAttribute(f, dir, "o", "etag", []byte("E")) + + attrs, err := s.ListAttributes(dir, "o") + if err != nil { + t.Fatalf("ListAttributes err: %v", err) + } + if len(attrs) != 0 { + t.Fatalf("ListAttributes = %v (want empty)", attrs) + } +} + +// TestAf2DescConcurrent: concurrent stores to distinct objects all succeed and +// round-trip (run with -race to exercise the sharded locking). +func TestAf2DescConcurrent(t *testing.T) { + dir := t.TempDir() + s := NewAf2Desc(0) + const n = 64 + + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + key := fmt.Sprintf("obj-%d", i) + f, err := os.Create(filepath.Join(dir, key)) + if err != nil { + t.Errorf("create %s: %v", key, err) + return + } + defer f.Close() + if err := s.StoreAttribute(f, dir, key, "etag", []byte(fmt.Sprintf("E%d", i))); err != nil { + t.Errorf("store %s: %v", key, err) + } + }(i) + } + wg.Wait() + + for i := 0; i < n; i++ { + key := fmt.Sprintf("obj-%d", i) + want := fmt.Sprintf("E%d", i) + if got, err := retr(t, s, dir, key, "etag"); err != nil || got != want { + t.Fatalf("%s etag = %q, %v (want %q)", key, got, err, want) + } + } +} diff --git a/cmd/versitygw/posix.go b/cmd/versitygw/posix.go index f63e7e68c..7e0115cb1 100644 --- a/cmd/versitygw/posix.go +++ b/cmd/versitygw/posix.go @@ -31,6 +31,8 @@ var ( dirPerms uint sidecar string nometa bool + af2Desc bool + af2DescMaxBytes int forceNoTmpFile bool forceNoCopyFileRange bool sameDirTmp bool @@ -116,6 +118,19 @@ will be translated into the file /mnt/fs/gwroot/mybucket/a/b/c/myobject`, EnvVars: []string{"VGW_DISABLE_COPY_FILE_RANGE"}, Destination: &forceNoCopyFileRange, }, + &cli.BoolFlag{ + Name: "af2-desc", + Usage: "store S3 object metadata (etag, content-type, user metadata) in the single AF2 DESC file attribute so it rides the AF2 snapshot; for CDM SDFS. Mutually exclusive with --sidecar and --nometa.", + EnvVars: []string{"VGW_AF2_DESC"}, + Destination: &af2Desc, + }, + &cli.IntFlag{ + Name: "af2-desc-max-bytes", + Usage: "maximum size of the packed AF2 DESC metadata blob (MJF attribute value cap)", + EnvVars: []string{"VGW_AF2_DESC_MAX_BYTES"}, + Value: meta.DefaultDescMaxBytes, + Destination: &af2DescMaxBytes, + }, &cli.BoolFlag{ Name: "same-dir-tmp", Usage: "create the atomic-write temp file in the object's own directory so the commit rename is same-directory; required for filesystems that reject cross-directory rename (e.g. SDFS). Use with --disableotmp on such filesystems.", @@ -141,6 +156,10 @@ func runPosix(ctx *cli.Context) error { return fmt.Errorf("cannot use both nometa and sidecar metadata") } + if af2Desc && (sidecar != "" || nometa) { + return fmt.Errorf("cannot combine af2-desc with sidecar or nometa metadata") + } + if actionsConcurrency <= 0 { return fmt.Errorf("concurrency must be positive, got %d", actionsConcurrency) } @@ -161,6 +180,8 @@ func runPosix(ctx *cli.Context) error { var ms meta.MetadataStorer switch { + case af2Desc: + ms = meta.NewAf2Desc(af2DescMaxBytes) case sidecar != "": sc, err := meta.NewSideCar(sidecar) if err != nil {