Files
versitygw/backend/meta/sidecar.go
Ben McClelland 27f04ad5ea feat: add windows functional test coverage and fix some windows behavior
This change adds Windows functional test execution in CI and updates
backend handling so windows filesystem error/path semantics map correctly
to expected S3 outcomes.

The only meta supported on windows right now is sidecar, so the tests
in windows mode also skip sidecar skips.

Future work is to address the skips and/or more clearly document
the unsupported/incompatible behavior on windows.

The windows support will still remain best effort, but these tests
should at least flag when future changes introduce incompatible
behavior on windows.
2026-06-18 11:34:05 -07:00

264 lines
7.2 KiB
Go

// Copyright 2025 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 (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"github.com/versity/versitygw/s3err"
)
// SideCar is a metadata storer that uses sidecar files to store metadata.
type SideCar struct {
dir string
}
const (
sidecarmeta = "meta"
)
// NewSideCar creates a new SideCar metadata storer.
func NewSideCar(dir string) (SideCar, error) {
fi, err := os.Lstat(dir)
if err != nil {
return SideCar{}, fmt.Errorf("failed to stat directory: %v", err)
}
if !fi.IsDir() {
return SideCar{}, fmt.Errorf("not a directory")
}
return SideCar{dir: dir}, nil
}
// RetrieveAttribute retrieves the value of a specific attribute for an object or a bucket.
func (s SideCar) RetrieveAttribute(_ *os.File, bucket, object, attribute string) ([]byte, error) {
bucket, object = trimVolume(bucket), trimVolume(object)
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
if object == "" {
metadir = filepath.Join(s.dir, bucket, sidecarmeta)
}
attr := filepath.Join(metadir, attribute)
value, err := os.ReadFile(attr)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNoSuchKey
}
if err != nil {
return nil, fmt.Errorf("failed to read attribute: %v", err)
}
return value, nil
}
// StoreAttribute stores the value of a specific attribute for an object or a bucket.
func (s SideCar) StoreAttribute(_ *os.File, bucket, object, attribute string, value []byte) error {
bucket, object = trimVolume(bucket), trimVolume(object)
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
if object == "" {
metadir = filepath.Join(s.dir, bucket, sidecarmeta)
}
err := os.MkdirAll(metadir, 0777)
if err != nil {
if errors.Is(err, syscall.ENOSPC) {
return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice)
}
return fmt.Errorf("failed to create metadata directory: %v", err)
}
attr := filepath.Join(metadir, attribute)
tempfile, err := os.CreateTemp(metadir, attribute)
if err != nil {
if errors.Is(err, syscall.ENOSPC) {
return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice)
}
return fmt.Errorf("failed to create temporary file: %v", err)
}
defer os.Remove(tempfile.Name())
_, err = tempfile.Write(value)
if err != nil {
tempfile.Close()
if errors.Is(err, syscall.ENOSPC) {
return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice)
}
return fmt.Errorf("failed to write attribute: %v", err)
}
// Close explicitly before rename to prevent error on Windows:
// The process cannot access the file because it is being used by another process.
if err = tempfile.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %v", err)
}
err = os.Rename(tempfile.Name(), attr)
if err != nil {
return fmt.Errorf("failed to rename temporary file: %v", err)
}
return nil
}
// DeleteAttribute removes the value of a specific attribute for an object or a bucket.
func (s SideCar) DeleteAttribute(bucket, object, attribute string) error {
bucket, object = trimVolume(bucket), trimVolume(object)
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
if object == "" {
metadir = filepath.Join(s.dir, bucket, sidecarmeta)
}
attr := filepath.Join(metadir, attribute)
err := os.Remove(attr)
if errors.Is(err, os.ErrNotExist) {
return ErrNoSuchKey
}
if err != nil {
return fmt.Errorf("failed to remove attribute: %v", err)
}
s.cleanupEmptyDirs(metadir, bucket, object)
return nil
}
// ListAttributes lists all attributes for an object or a bucket.
func (s SideCar) ListAttributes(bucket, object string) ([]string, error) {
bucket, object = trimVolume(bucket), trimVolume(object)
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
if object == "" {
metadir = filepath.Join(s.dir, bucket, sidecarmeta)
}
ents, err := os.ReadDir(metadir)
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
if err != nil {
return nil, fmt.Errorf("failed to list attributes: %v", err)
}
var attrs []string
for _, ent := range ents {
attrs = append(attrs, ent.Name())
}
return attrs, nil
}
// DeleteAttributes removes all attributes for an object or a bucket.
// When object is empty the entire bucket sidecar directory is removed,
// cleaning up any orphaned object or multipart metadata within it.
func (s SideCar) DeleteAttributes(bucket, object string) error {
bucket, object = trimVolume(bucket), trimVolume(object)
if object == "" {
// Remove the entire bucket sidecar directory so that orphaned
// object/multipart metadata does not accumulate after DeleteBucket.
bucketDir := filepath.Join(s.dir, bucket)
err := os.RemoveAll(bucketDir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove bucket attributes: %w", err)
}
return nil
}
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
err := os.RemoveAll(metadir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove attributes: %w", err)
}
s.cleanupEmptyDirs(metadir, bucket, object)
return nil
}
// RenameObject renames the sidecar metadata directory from oldObject to
// newObject so that path-based lookups continue to work after the data
// directory has been renamed.
func (s SideCar) RenameObject(bucket, oldObject, newObject string) error {
bucket = trimVolume(bucket)
oldPath := filepath.Join(s.dir, bucket, trimVolume(oldObject))
newPath := filepath.Join(s.dir, bucket, trimVolume(newObject))
if err := os.MkdirAll(filepath.Dir(newPath), 0777); err != nil {
if errors.Is(err, syscall.ENOSPC) {
return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice)
}
return fmt.Errorf("create parent for renamed metadata: %w", err)
}
err := os.Rename(oldPath, newPath)
if errors.Is(err, os.ErrNotExist) {
// No metadata stored yet — nothing to rename.
return nil
}
return err
}
func (s SideCar) cleanupEmptyDirs(metadir, bucket, object string) {
removeIfEmpty(metadir)
if bucket == "" {
return
}
bucketDir := filepath.Join(s.dir, bucket)
if object != "" {
removeEmptyParents(filepath.Dir(metadir), bucketDir)
}
removeIfEmpty(bucketDir)
}
func removeIfEmpty(dir string) {
empty, err := isDirEmpty(dir)
if err != nil || !empty {
return
}
_ = os.Remove(dir)
}
func removeEmptyParents(dir, stopDir string) {
for {
if dir == stopDir || dir == "." || dir == string(filepath.Separator) {
return
}
empty, err := isDirEmpty(dir)
if err != nil || !empty {
return
}
err = os.Remove(dir)
if err != nil {
return
}
dir = filepath.Dir(dir)
}
}
func isDirEmpty(dir string) (bool, error) {
f, err := os.Open(dir)
if err != nil {
return false, err
}
defer f.Close()
ents, err := f.Readdirnames(1)
if err == io.EOF {
return true, nil
}
if err != nil {
return false, err
}
return len(ents) == 0, nil
}