[mirror] Scalable static site server for Git forges (like GitHub Pages)
at main 24 kB view raw
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}