diff --git a/src/backend_fs.go b/src/backend_fs.go index 52ad790..3956860 100644 --- a/src/backend_fs.go +++ b/src/backend_fs.go @@ -217,6 +217,7 @@ func (fs *FSBackend) GetManifest( } return manifest, ManifestMetadata{ LastModified: stat.ModTime(), + ETag: fmt.Sprintf("%x", sha256.Sum256(data)), }, nil } @@ -307,6 +308,17 @@ func (fs *FSBackend) checkManifestPrecondition( } } + if opts.IfMatch != "" { + data, err := fs.siteRoot.ReadFile(name) + if err != nil { + return fmt.Errorf("read: %w", err) + } + + if fmt.Sprintf("%x", sha256.Sum256(data)) != opts.IfMatch { + return fmt.Errorf("%w: If-Match", ErrPreconditionFailed) + } + } + return nil } diff --git a/src/backend_s3.go b/src/backend_s3.go index a9e470b..76bf205 100644 --- a/src/backend_s3.go +++ b/src/backend_s3.go @@ -553,16 +553,17 @@ func (s3 *S3Backend) HasAtomicCAS(ctx context.Context) bool { func (s3 *S3Backend) checkManifestPrecondition( ctx context.Context, name string, opts ModifyManifestOptions, ) error { - if !opts.IfUnmodifiedSince.IsZero() { - stat, err := s3.client.StatObject(ctx, s3.bucket, manifestObjectName(name), - minio.GetObjectOptions{}) - if err != nil { - return fmt.Errorf("stat: %w", err) - } + stat, err := s3.client.StatObject(ctx, s3.bucket, manifestObjectName(name), + minio.GetObjectOptions{}) + if err != nil { + return err + } - if stat.LastModified.Compare(opts.IfUnmodifiedSince) > 0 { - return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed) - } + if !opts.IfUnmodifiedSince.IsZero() && stat.LastModified.Compare(opts.IfUnmodifiedSince) > 0 { + return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed) + } + if opts.IfMatch != "" && stat.ETag != opts.IfMatch { + return fmt.Errorf("%w: If-Match", ErrPreconditionFailed) } return nil @@ -585,8 +586,16 @@ func (s3 *S3Backend) CommitManifest( // Remove staged object unconditionally (whether commit succeeded or failed), since // the upper layer has to retry the complete operation anyway. + putOptions := minio.PutObjectOptions{} + putOptions.Header().Add("X-Tigris-Consistent", "true") + if opts.IfMatch != "" { + // Not guaranteed to do anything (see `HasAtomicCAS`), but let's try anyway; + // this is a "belt and suspenders" approach, together with `checkManifestPrecondition`. + // It does reliably work on MinIO at least. + putOptions.SetMatchETag(opts.IfMatch) + } _, putErr := s3.client.PutObject(ctx, s3.bucket, manifestObjectName(name), - bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{}) + bytes.NewReader(data), int64(len(data)), putOptions) removeErr := s3.client.RemoveObject(ctx, s3.bucket, stagedManifestObjectName(data), minio.RemoveObjectOptions{}) s3.siteCache.Cache.Invalidate(name) diff --git a/src/update.go b/src/update.go index c0bcec1..3e1f454 100644 --- a/src/update.go +++ b/src/update.go @@ -204,7 +204,10 @@ func PartialUpdateFromArchive( result = UpdateResult{UpdateError, nil, err} } else { result = Update(ctx, webRoot, oldManifest, newManifest, - ModifyManifestOptions{IfUnmodifiedSince: oldMetadata.LastModified}) + ModifyManifestOptions{ + IfUnmodifiedSince: oldMetadata.LastModified, + IfMatch: oldMetadata.ETag, + }) // The `If-Unmodified-Since` precondition is internally generated here, which means its // failure shouldn't be surfaced as-is in the HTTP response. If we also accepted options // from the client, then that precondition failure should surface in the response.