[mirror] Scalable static site server for Git forges (like GitHub Pages)
1package git_pages
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "fmt"
8 "io"
9 "iter"
10 "net/http"
11 "path"
12 "strings"
13 "time"
14
15 "github.com/c2h5oh/datasize"
16 "github.com/maypok86/otter/v2"
17 "github.com/minio/minio-go/v7"
18 "github.com/minio/minio-go/v7/pkg/credentials"
19 "github.com/prometheus/client_golang/prometheus"
20 "github.com/prometheus/client_golang/prometheus/promauto"
21)
22
23var (
24 blobsDedupedCount prometheus.Counter
25 blobsDedupedBytes prometheus.Counter
26
27 blobCacheHitsCount prometheus.Counter
28 blobCacheHitsBytes prometheus.Counter
29 blobCacheMissesCount prometheus.Counter
30 blobCacheMissesBytes prometheus.Counter
31 blobCacheEvictionsCount prometheus.Counter
32 blobCacheEvictionsBytes prometheus.Counter
33
34 manifestCacheHitsCount prometheus.Counter
35 manifestCacheMissesCount prometheus.Counter
36 manifestCacheEvictionsCount prometheus.Counter
37
38 s3GetObjectDurationSeconds *prometheus.HistogramVec
39 s3GetObjectResponseCount *prometheus.CounterVec
40)
41
42func initS3BackendMetrics() {
43 blobsDedupedCount = promauto.NewCounter(prometheus.CounterOpts{
44 Name: "git_pages_blobs_deduped",
45 Help: "Count of blobs deduplicated",
46 })
47 blobsDedupedBytes = promauto.NewCounter(prometheus.CounterOpts{
48 Name: "git_pages_blobs_deduped_bytes",
49 Help: "Total size in bytes of blobs deduplicated",
50 })
51
52 blobCacheHitsCount = promauto.NewCounter(prometheus.CounterOpts{
53 Name: "git_pages_blob_cache_hits_count",
54 Help: "Count of blobs that were retrieved from the cache",
55 })
56 blobCacheHitsBytes = promauto.NewCounter(prometheus.CounterOpts{
57 Name: "git_pages_blob_cache_hits_bytes",
58 Help: "Total size in bytes of blobs that were retrieved from the cache",
59 })
60 blobCacheMissesCount = promauto.NewCounter(prometheus.CounterOpts{
61 Name: "git_pages_blob_cache_misses_count",
62 Help: "Count of blobs that were not found in the cache (and were then successfully cached)",
63 })
64 blobCacheMissesBytes = promauto.NewCounter(prometheus.CounterOpts{
65 Name: "git_pages_blob_cache_misses_bytes",
66 Help: "Total size in bytes of blobs that were not found in the cache (and were then successfully cached)",
67 })
68 blobCacheEvictionsCount = promauto.NewCounter(prometheus.CounterOpts{
69 Name: "git_pages_blob_cache_evictions_count",
70 Help: "Count of blobs evicted from the cache",
71 })
72 blobCacheEvictionsBytes = promauto.NewCounter(prometheus.CounterOpts{
73 Name: "git_pages_blob_cache_evictions_bytes",
74 Help: "Total size in bytes of blobs evicted from the cache",
75 })
76
77 manifestCacheHitsCount = promauto.NewCounter(prometheus.CounterOpts{
78 Name: "git_pages_manifest_cache_hits_count",
79 Help: "Count of manifests that were retrieved from the cache",
80 })
81 manifestCacheMissesCount = promauto.NewCounter(prometheus.CounterOpts{
82 Name: "git_pages_manifest_cache_misses_count",
83 Help: "Count of manifests that were not found in the cache (and were then successfully cached)",
84 })
85 manifestCacheEvictionsCount = promauto.NewCounter(prometheus.CounterOpts{
86 Name: "git_pages_manifest_cache_evictions_count",
87 Help: "Count of manifests evicted from the cache",
88 })
89
90 s3GetObjectDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
91 Name: "git_pages_s3_get_object_duration_seconds",
92 Help: "Time to read a whole object from S3",
93 Buckets: []float64{.01, .025, .05, .1, .25, .5, .75, 1, 1.25, 1.5, 1.75, 2, 2.5, 5, 10},
94
95 NativeHistogramBucketFactor: 1.1,
96 NativeHistogramMaxBucketNumber: 100,
97 NativeHistogramMinResetDuration: 10 * time.Minute,
98 }, []string{"kind"})
99 s3GetObjectResponseCount = promauto.NewCounterVec(prometheus.CounterOpts{
100 Name: "git_pages_s3_get_object_responses_count",
101 Help: "Count of s3:GetObject responses",
102 }, []string{"kind", "code"})
103}
104
105// Blobs can be safely cached indefinitely. They only need to be evicted to preserve memory.
106type CachedBlob struct {
107 blob []byte
108 mtime time.Time
109}
110
111func (c *CachedBlob) Weight() uint32 { return uint32(len(c.blob)) }
112
113// Manifests can only be cached for a short time to avoid serving stale content. Browser
114// page loads cause a large burst of manifest accesses that are essential for serving
115// `304 No Content` responses and these need to be handled very quickly, so both hits and
116// misses are cached.
117type CachedManifest struct {
118 manifest *Manifest
119 weight uint32
120 metadata ManifestMetadata
121 err error
122}
123
124func (c *CachedManifest) Weight() uint32 { return c.weight }
125
126type S3Backend struct {
127 client *minio.Client
128 bucket string
129 blobCache *observedCache[string, *CachedBlob]
130 siteCache *observedCache[string, *CachedManifest]
131 featureCache *otter.Cache[BackendFeature, bool]
132}
133
134var _ Backend = (*S3Backend)(nil)
135
136func makeCacheOptions[K comparable, V any](
137 config *CacheConfig,
138 weigher func(K, V) uint32,
139) *otter.Options[K, V] {
140 options := &otter.Options[K, V]{}
141 if config.MaxSize != 0 {
142 options.MaximumWeight = config.MaxSize.Bytes()
143 options.Weigher = weigher
144 }
145 if config.MaxStale != 0 {
146 options.RefreshCalculator = otter.RefreshWriting[K, V](
147 time.Duration(config.MaxAge))
148 }
149 if config.MaxAge != 0 || config.MaxStale != 0 {
150 options.ExpiryCalculator = otter.ExpiryWriting[K, V](
151 time.Duration(config.MaxAge + config.MaxStale))
152 }
153 return options
154}
155
156func NewS3Backend(ctx context.Context, config *S3Config) (*S3Backend, error) {
157 client, err := minio.New(config.Endpoint, &minio.Options{
158 Creds: credentials.NewStaticV4(
159 config.AccessKeyID,
160 config.SecretAccessKey,
161 "",
162 ),
163 Secure: !config.Insecure,
164 })
165 if err != nil {
166 return nil, err
167 }
168
169 bucket := config.Bucket
170 exists, err := client.BucketExists(ctx, bucket)
171 if err != nil {
172 return nil, err
173 } else if !exists {
174 logc.Printf(ctx, "s3: create bucket %s\n", bucket)
175
176 err = client.MakeBucket(ctx, bucket,
177 minio.MakeBucketOptions{Region: config.Region})
178 if err != nil {
179 return nil, err
180 }
181 }
182
183 initS3BackendMetrics()
184
185 blobCacheMetrics := observedCacheMetrics{
186 HitNumberCounter: blobCacheHitsCount,
187 HitWeightCounter: blobCacheHitsBytes,
188 MissNumberCounter: blobCacheMissesCount,
189 MissWeightCounter: blobCacheMissesBytes,
190 EvictionNumberCounter: blobCacheEvictionsCount,
191 EvictionWeightCounter: blobCacheEvictionsBytes,
192 }
193 blobCache, err := newObservedCache(makeCacheOptions(&config.BlobCache,
194 func(key string, value *CachedBlob) uint32 { return uint32(len(value.blob)) }),
195 blobCacheMetrics)
196 if err != nil {
197 return nil, err
198 }
199
200 siteCacheMetrics := observedCacheMetrics{
201 HitNumberCounter: manifestCacheHitsCount,
202 MissNumberCounter: manifestCacheMissesCount,
203 EvictionNumberCounter: manifestCacheEvictionsCount,
204 }
205 siteCache, err := newObservedCache(makeCacheOptions(&config.SiteCache,
206 func(key string, value *CachedManifest) uint32 { return value.weight }),
207 siteCacheMetrics)
208 if err != nil {
209 return nil, err
210 }
211
212 featureCache, err := otter.New(&otter.Options[BackendFeature, bool]{
213 RefreshCalculator: otter.RefreshWriting[BackendFeature, bool](10 * time.Minute),
214 })
215 if err != nil {
216 return nil, err
217 }
218
219 return &S3Backend{client, bucket, blobCache, siteCache, featureCache}, nil
220}
221
222func (s3 *S3Backend) Backend() Backend {
223 return s3
224}
225
226func blobObjectName(name string) string {
227 return fmt.Sprintf("blob/%s", path.Join(splitBlobName(name)...))
228}
229
230func storeFeatureObjectName(feature BackendFeature) string {
231 return fmt.Sprintf("meta/feature/%s", feature)
232}
233
234func (s3 *S3Backend) HasFeature(ctx context.Context, feature BackendFeature) bool {
235 loader := func(ctx context.Context, feature BackendFeature) (bool, error) {
236 _, err := s3.client.StatObject(ctx, s3.bucket, storeFeatureObjectName(feature),
237 minio.StatObjectOptions{})
238 if err != nil {
239 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" {
240 logc.Printf(ctx, "s3 feature %q: disabled", feature)
241 return false, nil
242 } else {
243 return false, err
244 }
245 }
246 logc.Printf(ctx, "s3 feature %q: enabled", feature)
247 return true, nil
248 }
249
250 isOn, err := s3.featureCache.Get(ctx, feature, otter.LoaderFunc[BackendFeature, bool](loader))
251 if err != nil {
252 err = fmt.Errorf("getting s3 backend feature %q: %w", feature, err)
253 ObserveError(err)
254 logc.Println(ctx, err)
255 return false
256 }
257 return isOn
258}
259
260func (s3 *S3Backend) EnableFeature(ctx context.Context, feature BackendFeature) error {
261 _, err := s3.client.PutObject(ctx, s3.bucket, storeFeatureObjectName(feature),
262 &bytes.Reader{}, 0, minio.PutObjectOptions{})
263 return err
264}
265
266func (s3 *S3Backend) GetBlob(
267 ctx context.Context, name string,
268) (
269 reader io.ReadSeeker, metadata BlobMetadata, err error,
270) {
271 loader := func(ctx context.Context, name string) (*CachedBlob, error) {
272 logc.Printf(ctx, "s3: get blob %s\n", name)
273
274 startTime := time.Now()
275
276 object, err := s3.client.GetObject(ctx, s3.bucket, blobObjectName(name),
277 minio.GetObjectOptions{})
278 // Note that many errors (e.g. NoSuchKey) will be reported only after this point.
279 if err != nil {
280 return nil, err
281 }
282 defer object.Close()
283
284 data, err := io.ReadAll(object)
285 if err != nil {
286 return nil, err
287 }
288
289 stat, err := object.Stat()
290 if err != nil {
291 return nil, err
292 }
293
294 s3GetObjectDurationSeconds.
295 With(prometheus.Labels{"kind": "blob"}).
296 Observe(time.Since(startTime).Seconds())
297
298 return &CachedBlob{data, stat.LastModified}, nil
299 }
300
301 observer := func(ctx context.Context, name string) (*CachedBlob, error) {
302 cached, err := loader(ctx, name)
303 var code = "OK"
304 if resp, ok := err.(minio.ErrorResponse); ok {
305 code = resp.Code
306 }
307 s3GetObjectResponseCount.With(prometheus.Labels{"kind": "blob", "code": code}).Inc()
308 return cached, err
309 }
310
311 var cached *CachedBlob
312 cached, err = s3.blobCache.Get(ctx, name, otter.LoaderFunc[string, *CachedBlob](observer))
313 if err != nil {
314 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" {
315 err = fmt.Errorf("%w: %s", ErrObjectNotFound, errResp.Key)
316 }
317 } else {
318 reader = bytes.NewReader(cached.blob)
319 metadata.Name = name
320 metadata.Size = int64(len(cached.blob))
321 metadata.LastModified = cached.mtime
322 }
323 return
324}
325
326func (s3 *S3Backend) PutBlob(ctx context.Context, name string, data []byte) error {
327 logc.Printf(ctx, "s3: put blob %s (%s)\n", name, datasize.ByteSize(len(data)).HumanReadable())
328
329 _, err := s3.client.StatObject(ctx, s3.bucket, blobObjectName(name),
330 minio.GetObjectOptions{})
331 if err != nil {
332 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" {
333 _, err := s3.client.PutObject(ctx, s3.bucket, blobObjectName(name),
334 bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{})
335 if err != nil {
336 return err
337 } else {
338 ObserveData(ctx, "blob.status", "created")
339 logc.Printf(ctx, "s3: put blob %s (created)\n", name)
340 return nil
341 }
342 } else {
343 return err
344 }
345 } else {
346 ObserveData(ctx, "blob.status", "exists")
347 logc.Printf(ctx, "s3: put blob %s (exists)\n", name)
348 blobsDedupedCount.Inc()
349 blobsDedupedBytes.Add(float64(len(data)))
350 return nil
351 }
352}
353
354func (s3 *S3Backend) DeleteBlob(ctx context.Context, name string) error {
355 logc.Printf(ctx, "s3: delete blob %s\n", name)
356
357 return s3.client.RemoveObject(ctx, s3.bucket, blobObjectName(name),
358 minio.RemoveObjectOptions{})
359}
360
361func (s3 *S3Backend) EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] {
362 return func(yield func(BlobMetadata, error) bool) {
363 logc.Print(ctx, "s3: enumerate blobs")
364
365 ctx, cancel := context.WithCancel(ctx)
366 defer cancel()
367
368 prefix := "blob/"
369 for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{
370 Prefix: prefix,
371 Recursive: true,
372 }) {
373 var metadata BlobMetadata
374 var err error
375 if err = object.Err; err == nil {
376 key := strings.TrimPrefix(object.Key, prefix)
377 if strings.HasSuffix(key, "/") {
378 continue // directory; skip
379 } else {
380 metadata.Name = joinBlobName(strings.Split(key, "/"))
381 metadata.Size = object.Size
382 metadata.LastModified = object.LastModified
383 }
384 }
385 if !yield(metadata, err) {
386 break
387 }
388 }
389 }
390}
391
392func manifestObjectName(name string) string {
393 return fmt.Sprintf("site/%s", name)
394}
395
396func stagedManifestObjectName(manifestData []byte) string {
397 return fmt.Sprintf("dirty/%x", sha256.Sum256(manifestData))
398}
399
400type s3ManifestLoader struct {
401 s3 *S3Backend
402}
403
404func (l s3ManifestLoader) Load(
405 ctx context.Context, key string,
406) (
407 *CachedManifest, error,
408) {
409 return l.load(ctx, key, nil)
410}
411
412func (l s3ManifestLoader) Reload(
413 ctx context.Context, key string, oldValue *CachedManifest,
414) (
415 *CachedManifest, error,
416) {
417 return l.load(ctx, key, oldValue)
418}
419
420func (l s3ManifestLoader) load(
421 ctx context.Context, name string, oldManifest *CachedManifest,
422) (
423 *CachedManifest, error,
424) {
425 logc.Printf(ctx, "s3: get manifest %s\n", name)
426
427 loader := func() (*CachedManifest, error) {
428 opts := minio.GetObjectOptions{}
429 if oldManifest != nil && oldManifest.metadata.ETag != "" {
430 opts.SetMatchETagExcept(oldManifest.metadata.ETag)
431 }
432 object, err := l.s3.client.GetObject(ctx, l.s3.bucket, manifestObjectName(name), opts)
433 // Note that many errors (e.g. NoSuchKey) will be reported only after this point.
434 if err != nil {
435 return nil, err
436 }
437 defer object.Close()
438
439 data, err := io.ReadAll(object)
440 if err != nil {
441 return nil, err
442 }
443
444 stat, err := object.Stat()
445 if err != nil {
446 return nil, err
447 }
448
449 manifest, err := DecodeManifest(data)
450 if err != nil {
451 return nil, err
452 }
453
454 metadata := ManifestMetadata{
455 LastModified: stat.LastModified,
456 ETag: stat.ETag,
457 }
458 return &CachedManifest{manifest, uint32(len(data)), metadata, nil}, nil
459 }
460
461 observer := func() (*CachedManifest, error) {
462 cached, err := loader()
463 var code = "OK"
464 if resp, ok := err.(minio.ErrorResponse); ok {
465 code = resp.Code
466 }
467 s3GetObjectResponseCount.With(prometheus.Labels{"kind": "manifest", "code": code}).Inc()
468 return cached, err
469 }
470
471 startTime := time.Now()
472 cached, err := observer()
473 s3GetObjectDurationSeconds.
474 With(prometheus.Labels{"kind": "manifest"}).
475 Observe(time.Since(startTime).Seconds())
476
477 if err != nil {
478 errResp := minio.ToErrorResponse(err)
479 if errResp.Code == "NoSuchKey" {
480 err = fmt.Errorf("%w: %s", ErrObjectNotFound, errResp.Key)
481 return &CachedManifest{nil, 1, ManifestMetadata{}, err}, nil
482 } else if errResp.StatusCode == http.StatusNotModified && oldManifest != nil {
483 return oldManifest, nil
484 } else {
485 return nil, err
486 }
487 } else {
488 return cached, nil
489 }
490}
491
492func (s3 *S3Backend) GetManifest(
493 ctx context.Context, name string, opts GetManifestOptions,
494) (
495 manifest *Manifest, metadata ManifestMetadata, err error,
496) {
497 if opts.BypassCache {
498 entry, found := s3.siteCache.Cache.GetEntry(name)
499 if found && entry.RefreshableAt().Before(time.Now()) {
500 s3.siteCache.Cache.Invalidate(name)
501 }
502 }
503
504 var cached *CachedManifest
505 cached, err = s3.siteCache.Get(ctx, name, s3ManifestLoader{s3})
506 if err != nil {
507 return
508 } else {
509 // This could be `manifest, mtime, nil` or `nil, time.Time{}, ErrObjectNotFound`.
510 manifest, metadata, err = cached.manifest, cached.metadata, cached.err
511 return
512 }
513}
514
515func (s3 *S3Backend) StageManifest(ctx context.Context, manifest *Manifest) error {
516 data := EncodeManifest(manifest)
517 logc.Printf(ctx, "s3: stage manifest %x\n", sha256.Sum256(data))
518
519 _, err := s3.client.PutObject(ctx, s3.bucket, stagedManifestObjectName(data),
520 bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{})
521 return err
522}
523
524func domainFrozenObjectName(domain string) string {
525 return manifestObjectName(fmt.Sprintf("%s/.frozen", domain))
526}
527
528func (s3 *S3Backend) checkDomainFrozen(ctx context.Context, domain string) error {
529 _, err := s3.client.StatObject(ctx, s3.bucket, domainFrozenObjectName(domain),
530 minio.GetObjectOptions{})
531 if err == nil {
532 return ErrDomainFrozen
533 } else if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" {
534 return nil
535 } else {
536 return err
537 }
538}
539
540func (s3 *S3Backend) HasAtomicCAS(ctx context.Context) bool {
541 // Support for `If-Unmodified-Since:` or `If-Match:` for PutObject requests is very spotty:
542 // - AWS supports only `If-Match:`:
543 // https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
544 // - Minio supports `If-Match:`:
545 // https://blog.min.io/leading-the-way-minios-conditional-write-feature-for-modern-data-workloads/
546 // - Tigris supports `If-Unmodified-Since:` and `If-Match:`, but only with `X-Tigris-Consistent: true`;
547 // https://www.tigrisdata.com/docs/objects/conditionals/
548 // Note that the `X-Tigris-Consistent: true` header must be present on *every* transaction
549 // touching the object, not just on the CAS transactions.
550 // - Wasabi does not support either one and docs seem to suggest that the headers are ignored;
551 // - Garage does not support either one and source code suggests the headers are ignored.
552 // It seems that the only safe option is to not claim support for atomic CAS, and only do
553 // best-effort CAS implementation using HeadObject and PutObject/DeleteObject.
554 return false
555}
556
557func (s3 *S3Backend) checkManifestPrecondition(
558 ctx context.Context, name string, opts ModifyManifestOptions,
559) error {
560 if opts.IfUnmodifiedSince.IsZero() && opts.IfMatch == "" {
561 return nil
562 }
563
564 stat, err := s3.client.StatObject(ctx, s3.bucket, manifestObjectName(name),
565 minio.GetObjectOptions{})
566 if err != nil {
567 return err
568 }
569
570 if !opts.IfUnmodifiedSince.IsZero() && stat.LastModified.Compare(opts.IfUnmodifiedSince) > 0 {
571 return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed)
572 }
573 if opts.IfMatch != "" && stat.ETag != opts.IfMatch {
574 return fmt.Errorf("%w: If-Match", ErrPreconditionFailed)
575 }
576
577 return nil
578}
579
580func (s3 *S3Backend) CommitManifest(
581 ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions,
582) error {
583 data := EncodeManifest(manifest)
584 logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name)
585
586 _, domain, _ := strings.Cut(name, "/")
587 if err := s3.checkDomainFrozen(ctx, domain); err != nil {
588 return err
589 }
590
591 if err := s3.checkManifestPrecondition(ctx, name, opts); err != nil {
592 return err
593 }
594
595 // Remove staged object unconditionally (whether commit succeeded or failed), since
596 // the upper layer has to retry the complete operation anyway.
597 putOptions := minio.PutObjectOptions{}
598 putOptions.Header().Add("X-Tigris-Consistent", "true")
599 if opts.IfMatch != "" {
600 // Not guaranteed to do anything (see `HasAtomicCAS`), but let's try anyway;
601 // this is a "belt and suspenders" approach, together with `checkManifestPrecondition`.
602 // It does reliably work on MinIO at least.
603 putOptions.SetMatchETag(opts.IfMatch)
604 }
605 _, putErr := s3.client.PutObject(ctx, s3.bucket, manifestObjectName(name),
606 bytes.NewReader(data), int64(len(data)), putOptions)
607 removeErr := s3.client.RemoveObject(ctx, s3.bucket, stagedManifestObjectName(data),
608 minio.RemoveObjectOptions{})
609 s3.siteCache.Cache.Invalidate(name)
610 if putErr != nil {
611 if errResp := minio.ToErrorResponse(putErr); errResp.Code == "PreconditionFailed" {
612 return ErrPreconditionFailed
613 } else {
614 return putErr
615 }
616 } else if removeErr != nil {
617 return removeErr
618 } else {
619 return nil
620 }
621}
622
623func (s3 *S3Backend) DeleteManifest(
624 ctx context.Context, name string, opts ModifyManifestOptions,
625) error {
626 logc.Printf(ctx, "s3: delete manifest %s\n", name)
627
628 _, domain, _ := strings.Cut(name, "/")
629 if err := s3.checkDomainFrozen(ctx, domain); err != nil {
630 return err
631 }
632
633 if err := s3.checkManifestPrecondition(ctx, name, opts); err != nil {
634 return err
635 }
636
637 err := s3.client.RemoveObject(ctx, s3.bucket, manifestObjectName(name),
638 minio.RemoveObjectOptions{})
639 s3.siteCache.Cache.Invalidate(name)
640 return err
641}
642
643func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] {
644 return func(yield func(ManifestMetadata, error) bool) {
645 logc.Print(ctx, "s3: enumerate manifests")
646
647 ctx, cancel := context.WithCancel(ctx)
648 defer cancel()
649
650 prefix := "site/"
651 for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{
652 Prefix: prefix,
653 Recursive: true,
654 }) {
655 var metadata ManifestMetadata
656 var err error
657 if err = object.Err; err == nil {
658 key := strings.TrimPrefix(object.Key, prefix)
659 _, project, _ := strings.Cut(key, "/")
660 if strings.HasSuffix(key, "/") {
661 continue // directory; skip
662 } else if project == "" || strings.HasPrefix(project, ".") && project != ".index" {
663 continue // internal; skip
664 } else {
665 metadata.Name = key
666 metadata.Size = object.Size
667 metadata.LastModified = object.LastModified
668 metadata.ETag = object.ETag
669 }
670 }
671 if !yield(metadata, err) {
672 break
673 }
674 }
675 }
676}
677
678func domainCheckObjectName(domain string) string {
679 return manifestObjectName(fmt.Sprintf("%s/.exists", domain))
680}
681
682func (s3 *S3Backend) CheckDomain(ctx context.Context, domain string) (exists bool, err error) {
683 logc.Printf(ctx, "s3: check domain %s\n", domain)
684
685 _, err = s3.client.StatObject(ctx, s3.bucket, domainCheckObjectName(domain),
686 minio.StatObjectOptions{})
687 if err != nil {
688 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" {
689 exists, err = false, nil
690 }
691 } else {
692 exists = true
693 }
694
695 if !exists && !s3.HasFeature(ctx, FeatureCheckDomainMarker) {
696 ctx, cancel := context.WithCancel(ctx)
697 defer cancel()
698
699 for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{
700 Prefix: manifestObjectName(fmt.Sprintf("%s/", domain)),
701 }) {
702 if object.Err != nil {
703 return false, object.Err
704 }
705 return true, nil
706 }
707 return false, nil
708 }
709
710 return
711}
712
713func (s3 *S3Backend) CreateDomain(ctx context.Context, domain string) error {
714 logc.Printf(ctx, "s3: create domain %s\n", domain)
715
716 _, err := s3.client.PutObject(ctx, s3.bucket, domainCheckObjectName(domain),
717 &bytes.Reader{}, 0, minio.PutObjectOptions{})
718 return err
719}
720
721func (s3 *S3Backend) FreezeDomain(ctx context.Context, domain string) error {
722 logc.Printf(ctx, "s3: freeze domain %s\n", domain)
723
724 _, err := s3.client.PutObject(ctx, s3.bucket, domainFrozenObjectName(domain),
725 &bytes.Reader{}, 0, minio.PutObjectOptions{})
726 return err
727
728}
729
730func (s3 *S3Backend) UnfreezeDomain(ctx context.Context, domain string) error {
731 logc.Printf(ctx, "s3: unfreeze domain %s\n", domain)
732
733 err := s3.client.RemoveObject(ctx, s3.bucket, domainFrozenObjectName(domain),
734 minio.RemoveObjectOptions{})
735 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" {
736 return nil
737 } else {
738 return err
739 }
740}
741
742func auditObjectName(id AuditID) string {
743 return fmt.Sprintf("audit/%s", id)
744}
745
746func (s3 *S3Backend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error {
747 logc.Printf(ctx, "s3: append audit %s\n", id)
748
749 name := auditObjectName(id)
750 data := EncodeAuditRecord(record)
751
752 options := minio.PutObjectOptions{}
753 options.SetMatchETagExcept("*") // may or may not be supported
754 _, err := s3.client.PutObject(ctx, s3.bucket, name,
755 bytes.NewReader(data), int64(len(data)), options)
756 if errResp := minio.ToErrorResponse(err); errResp.StatusCode == 412 {
757 panic(fmt.Errorf("audit ID collision: %s", name))
758 }
759 return err
760}
761
762func (s3 *S3Backend) QueryAuditLog(ctx context.Context, id AuditID) (*AuditRecord, error) {
763 logc.Printf(ctx, "s3: read audit %s\n", id)
764
765 object, err := s3.client.GetObject(ctx, s3.bucket, auditObjectName(id),
766 minio.GetObjectOptions{})
767 if err != nil {
768 return nil, err
769 }
770 defer object.Close()
771
772 data, err := io.ReadAll(object)
773 if err != nil {
774 return nil, err
775 }
776
777 return DecodeAuditRecord(data)
778}
779
780func (s3 *S3Backend) SearchAuditLog(
781 ctx context.Context, opts SearchAuditLogOptions,
782) iter.Seq2[AuditID, error] {
783 return func(yield func(AuditID, error) bool) {
784 logc.Printf(ctx, "s3: search audit\n")
785
786 ctx, cancel := context.WithCancel(ctx)
787 defer cancel()
788
789 prefix := "audit/"
790 for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{
791 Prefix: prefix,
792 }) {
793 var id AuditID
794 var err error
795 if object.Err != nil {
796 err = object.Err
797 } else {
798 id, err = ParseAuditID(strings.TrimPrefix(object.Key, prefix))
799 }
800 if !yield(id, err) {
801 break
802 }
803 }
804 }
805}