[mirror] Scalable static site server for Git forges (like GitHub Pages)

Allow `PATCH` method to apply partial updates.

Gated behind the `patch` feature.

+14 -4
README.md
··· 69 - If the `PUT` method receives an `application/x-tar`, `application/x-tar+gzip`, `application/x-tar+zstd`, or `application/zip` body, it contains an archive to be extracted. 70 - The `POST` method requires an `application/json` body containing a Forgejo/Gitea/Gogs/GitHub webhook event payload. Requests where the `ref` key contains anything other than `refs/heads/pages` are ignored, and only the `pages` branch is used. The `repository.clone_url` key contains a repository URL to be shallowly cloned. 71 - If the received contents is empty, performs the same action as `DELETE`. 72 * In response to a `DELETE` request, the server unpublishes a site. The URL of the request must be the root URL of the site that is being unpublished. Site data remains stored for an indeterminate period of time, but becomes completely inaccessible. 73 - * If a `Dry-Run: yes` header is provided with a `PUT`, `DELETE`, or `POST` request, only the authorization checks are run; no destructive updates are made. Note that this functionality was added in _git-pages_ v0.2.0. 74 * All updates to site content are atomic (subject to consistency guarantees of the storage backend). That is, there is an instantaneous moment during an update before which the server will return the old content and after which it will return the new content. 75 * Files with a certain name, when placed in the root of a site, have special functions: 76 - [Netlify `_redirects`][_redirects] file can be used to specify HTTP redirect and rewrite rules. The _git-pages_ implementation currently does not support placeholders, query parameters, or conditions, and may differ from Netlify in other minor ways. If you find that a supported `_redirects` file feature does not work the same as on Netlify, please file an issue. (Note that _git-pages_ does not perform URL normalization; `/foo` and `/foo/` are *not* the same, unlike with Netlify.) ··· 81 [_headers]: https://docs.netlify.com/manage/routing/headers/ 82 [cors]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS 83 [go-git-sha256]: https://github.com/go-git/go-git/issues/706 84 85 86 Authorization ··· 88 89 DNS is the primary authorization method, using either TXT records or wildcard matching. In certain cases, git forge authorization is used in addition to DNS. 90 91 - The authorization flow for content updates (`PUT`, `DELETE`, `POST` requests) proceeds sequentially in the following order, with the first of multiple applicable rule taking precedence: 92 93 1. **Development Mode:** If the environment variable `PAGES_INSECURE` is set to a truthful value like `1`, the request is authorized. 94 - 2. **DNS Challenge:** If the method is `PUT`, `DELETE`, `POST`, and a well-formed `Authorization:` header is provided containing a `<token>`, and a TXT record lookup at `_git-pages-challenge.<host>` returns a record whose concatenated value equals `SHA256("<host> <token>")`, the request is authorized. 95 - **`Pages` scheme:** Request includes an `Authorization: Pages <token>` header. 96 - **`Basic` scheme:** Request includes an `Authorization: Basic <basic>` header, where `<basic>` is equal to `Base64("Pages:<token>")`. (Useful for non-Forgejo forges.) 97 3. **DNS Allowlist:** If the method is `PUT` or `POST`, and the request URL is `scheme://<user>.<host>/`, and a TXT record lookup at `_git-pages-repository.<host>` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized. 98 4. **Wildcard Match (content):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. 99 - **Index repository:** If the request URL is `scheme://<user>.<host>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating each element of `[[wildcard]].index-repos` with `<user>`, and `[[wildcard]]` is the section where the match occurred. 100 - **Project repository:** If the request URL is `scheme://<user>.<host>/<project>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, and `[[wildcard]]` is the section where the match occurred. 101 - 5. **Forge Authorization:** If the method is `PUT`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized. (This enables publishing a site for a private repository.) 102 5. **Default Deny:** Otherwise, the request is not authorized. 103 104 The authorization flow for metadata retrieval (`GET` requests with site paths starting with `.git-pages/`) in the following order, with the first of multiple applicable rule taking precedence:
··· 69 - If the `PUT` method receives an `application/x-tar`, `application/x-tar+gzip`, `application/x-tar+zstd`, or `application/zip` body, it contains an archive to be extracted. 70 - The `POST` method requires an `application/json` body containing a Forgejo/Gitea/Gogs/GitHub webhook event payload. Requests where the `ref` key contains anything other than `refs/heads/pages` are ignored, and only the `pages` branch is used. The `repository.clone_url` key contains a repository URL to be shallowly cloned. 71 - If the received contents is empty, performs the same action as `DELETE`. 72 + * **With feature `patch`:** In response to a `PATCH` request, the server partially updates a site with new content. The URL of the request must be the root URL of the site that is being published. 73 + - The request must have a `application/x-tar`, `application/x-tar+gzip`, or `application/x-tar+zstd` body, whose contents is *merged* with the existing site contents as follows: 74 + - A character device entry with major 0 and minor 0 is treated as a "whiteout marker" (following [unionfs][whiteout]): it causes any existing file or directory with the same name to be deleted. 75 + - A directory entry replaces any existing file or directory with the same name (if any), recursively removing the old contents. 76 + - A file or symlink entry replaces any existing file or directory with the same name (if any). 77 + - In any case, the parent of an entry must exist and be a directory. 78 + - The request must have a `Race-Free: yes` or `Race-Free: no` header. Not every backend configuration makes it possible to perform atomic compare-and-swap operations; on backends without atomic CAS support, `Race-Free: yes` requests will fail, while `Race-Free: no` requests will provide a best-effort approximation. 79 + - If a `PATCH` request loses a race against another content update request, it may return `409 Conflict`. This is true regardless of the `Race-Free:` header value. Whenever this happens, resubmit the request as-is. 80 + - If the site has no contents after the update is applied, performs the same action as `DELETE`. 81 * In response to a `DELETE` request, the server unpublishes a site. The URL of the request must be the root URL of the site that is being unpublished. Site data remains stored for an indeterminate period of time, but becomes completely inaccessible. 82 + * If a `Dry-Run: yes` header is provided with a `PUT`, `PATCH`, `DELETE`, or `POST` request, only the authorization checks are run; no destructive updates are made. Note that this functionality was added in _git-pages_ v0.2.0. 83 * All updates to site content are atomic (subject to consistency guarantees of the storage backend). That is, there is an instantaneous moment during an update before which the server will return the old content and after which it will return the new content. 84 * Files with a certain name, when placed in the root of a site, have special functions: 85 - [Netlify `_redirects`][_redirects] file can be used to specify HTTP redirect and rewrite rules. The _git-pages_ implementation currently does not support placeholders, query parameters, or conditions, and may differ from Netlify in other minor ways. If you find that a supported `_redirects` file feature does not work the same as on Netlify, please file an issue. (Note that _git-pages_ does not perform URL normalization; `/foo` and `/foo/` are *not* the same, unlike with Netlify.) ··· 90 [_headers]: https://docs.netlify.com/manage/routing/headers/ 91 [cors]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS 92 [go-git-sha256]: https://github.com/go-git/go-git/issues/706 93 + [whiteout]: https://docs.kernel.org/filesystems/overlayfs.html#whiteouts-and-opaque-directories 94 95 96 Authorization ··· 98 99 DNS is the primary authorization method, using either TXT records or wildcard matching. In certain cases, git forge authorization is used in addition to DNS. 100 101 + The authorization flow for content updates (`PUT`, `PATCH`, `DELETE`, `POST` requests) proceeds sequentially in the following order, with the first of multiple applicable rule taking precedence: 102 103 1. **Development Mode:** If the environment variable `PAGES_INSECURE` is set to a truthful value like `1`, the request is authorized. 104 + 2. **DNS Challenge:** If the method is `PUT`, `PATCH`, `DELETE`, `POST`, and a well-formed `Authorization:` header is provided containing a `<token>`, and a TXT record lookup at `_git-pages-challenge.<host>` returns a record whose concatenated value equals `SHA256("<host> <token>")`, the request is authorized. 105 - **`Pages` scheme:** Request includes an `Authorization: Pages <token>` header. 106 - **`Basic` scheme:** Request includes an `Authorization: Basic <basic>` header, where `<basic>` is equal to `Base64("Pages:<token>")`. (Useful for non-Forgejo forges.) 107 3. **DNS Allowlist:** If the method is `PUT` or `POST`, and the request URL is `scheme://<user>.<host>/`, and a TXT record lookup at `_git-pages-repository.<host>` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized. 108 4. **Wildcard Match (content):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. 109 - **Index repository:** If the request URL is `scheme://<user>.<host>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating each element of `[[wildcard]].index-repos` with `<user>`, and `[[wildcard]]` is the section where the match occurred. 110 - **Project repository:** If the request URL is `scheme://<user>.<host>/<project>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, and `[[wildcard]]` is the section where the match occurred. 111 + 5. **Forge Authorization:** If the method is `PUT` or `PATCH`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized. (This enables publishing a site for a private repository.) 112 5. **Default Deny:** Otherwise, the request is not authorized. 113 114 The authorization flow for metadata retrieval (`GET` requests with site paths starting with `.git-pages/`) in the following order, with the first of multiple applicable rule taking precedence:
+8 -4
src/audit.go
··· 144 } 145 } 146 147 - func (audited *auditedBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) (err error) { 148 domain, project, ok := strings.Cut(name, "/") 149 if !ok { 150 panic("malformed manifest name") ··· 156 Manifest: manifest, 157 }) 158 159 - return audited.Backend.CommitManifest(ctx, name, manifest) 160 } 161 162 - func (audited *auditedBackend) DeleteManifest(ctx context.Context, name string) (err error) { 163 domain, project, ok := strings.Cut(name, "/") 164 if !ok { 165 panic("malformed manifest name") ··· 170 Project: proto.String(project), 171 }) 172 173 - return audited.Backend.DeleteManifest(ctx, name) 174 } 175 176 func (audited *auditedBackend) FreezeDomain(ctx context.Context, domain string, freeze bool) (err error) {
··· 144 } 145 } 146 147 + func (audited *auditedBackend) CommitManifest( 148 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 149 + ) (err error) { 150 domain, project, ok := strings.Cut(name, "/") 151 if !ok { 152 panic("malformed manifest name") ··· 158 Manifest: manifest, 159 }) 160 161 + return audited.Backend.CommitManifest(ctx, name, manifest, opts) 162 } 163 164 + func (audited *auditedBackend) DeleteManifest( 165 + ctx context.Context, name string, opts ModifyManifestOptions, 166 + ) (err error) { 167 domain, project, ok := strings.Cut(name, "/") 168 if !ok { 169 panic("malformed manifest name") ··· 174 Project: proto.String(project), 175 }) 176 177 + return audited.Backend.DeleteManifest(ctx, name, opts) 178 } 179 180 func (audited *auditedBackend) FreezeDomain(ctx context.Context, domain string, freeze bool) (err error) {
+16 -3
src/backend.go
··· 12 ) 13 14 var ErrObjectNotFound = errors.New("not found") 15 var ErrDomainFrozen = errors.New("domain administratively frozen") 16 17 func splitBlobName(name string) []string { ··· 33 // If true and the manifest is past the cache `MaxAge`, `GetManifest` blocks and returns 34 // a fresh object instead of revalidating in background and returning a stale object. 35 BypassCache bool 36 } 37 38 type QueryAuditLogOptions struct { ··· 81 // effects. 82 StageManifest(ctx context.Context, manifest *Manifest) error 83 84 // Commit a manifest. This is an atomic operation; `GetManifest` calls will return either 85 // the old version or the new version of the manifest, never anything else. 86 - CommitManifest(ctx context.Context, name string, manifest *Manifest) error 87 88 // Delete a manifest. 89 - DeleteManifest(ctx context.Context, name string) error 90 91 // List all manifests. 92 ListManifests(ctx context.Context) (manifests []string, err error) ··· 114 func CreateBackend(config *StorageConfig) (backend Backend, err error) { 115 switch config.Type { 116 case "fs": 117 - if backend, err = NewFSBackend(&config.FS); err != nil { 118 err = fmt.Errorf("fs backend: %w", err) 119 } 120 case "s3":
··· 12 ) 13 14 var ErrObjectNotFound = errors.New("not found") 15 + var ErrPreconditionFailed = errors.New("precondition failed") 16 + var ErrWriteConflict = errors.New("write conflict") 17 var ErrDomainFrozen = errors.New("domain administratively frozen") 18 19 func splitBlobName(name string) []string { ··· 35 // If true and the manifest is past the cache `MaxAge`, `GetManifest` blocks and returns 36 // a fresh object instead of revalidating in background and returning a stale object. 37 BypassCache bool 38 + } 39 + 40 + type ModifyManifestOptions struct { 41 + // If non-zero, the request will only succeed if the manifest hasn't been changed since 42 + // the given time. Whether this is racy or not is can be determined via `HasAtomicCAS()`. 43 + IfUnmodifiedSince time.Time 44 } 45 46 type QueryAuditLogOptions struct { ··· 89 // effects. 90 StageManifest(ctx context.Context, manifest *Manifest) error 91 92 + // Whether a compare-and-swap operation on a manifest is truly race-free, or only best-effort 93 + // atomic with a small but non-zero window where two requests may race where the one committing 94 + // first will have its update lost. (Plain swap operations are always guaranteed to be atomic.) 95 + HasAtomicCAS(ctx context.Context) bool 96 + 97 // Commit a manifest. This is an atomic operation; `GetManifest` calls will return either 98 // the old version or the new version of the manifest, never anything else. 99 + CommitManifest(ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions) error 100 101 // Delete a manifest. 102 + DeleteManifest(ctx context.Context, name string, opts ModifyManifestOptions) error 103 104 // List all manifests. 105 ListManifests(ctx context.Context) (manifests []string, err error) ··· 127 func CreateBackend(config *StorageConfig) (backend Backend, err error) { 128 switch config.Type { 129 case "fs": 130 + if backend, err = NewFSBackend(context.Background(), &config.FS); err != nil { 131 err = fmt.Errorf("fs backend: %w", err) 132 } 133 case "s3":
+110 -7
src/backend_fs.go
··· 11 "os" 12 "path/filepath" 13 "strings" 14 "time" 15 ) 16 17 type FSBackend struct { 18 - blobRoot *os.Root 19 - siteRoot *os.Root 20 - auditRoot *os.Root 21 } 22 23 var _ Backend = (*FSBackend)(nil) ··· 56 return tempPath, nil 57 } 58 59 - func NewFSBackend(config *FSConfig) (*FSBackend, error) { 60 blobRoot, err := maybeCreateOpenRoot(config.Root, "blob") 61 if err != nil { 62 return nil, fmt.Errorf("blob: %w", err) ··· 69 if err != nil { 70 return nil, fmt.Errorf("audit: %w", err) 71 } 72 - return &FSBackend{blobRoot, siteRoot, auditRoot}, nil 73 } 74 75 func (fs *FSBackend) Backend() Backend { ··· 229 } 230 } 231 232 - func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 233 domain := filepath.Dir(name) 234 if err := fs.checkDomainFrozen(ctx, domain); err != nil { 235 return err 236 } 237 ··· 253 return nil 254 } 255 256 - func (fs *FSBackend) DeleteManifest(ctx context.Context, name string) error { 257 domain := filepath.Dir(name) 258 if err := fs.checkDomainFrozen(ctx, domain); err != nil { 259 return err 260 } 261
··· 11 "os" 12 "path/filepath" 13 "strings" 14 + "sync" 15 "time" 16 ) 17 18 type FSBackend struct { 19 + blobRoot *os.Root 20 + siteRoot *os.Root 21 + auditRoot *os.Root 22 + hasAtomicCAS bool 23 } 24 25 var _ Backend = (*FSBackend)(nil) ··· 58 return tempPath, nil 59 } 60 61 + func checkAtomicCAS(root *os.Root) bool { 62 + fileName := ".hasAtomicCAS" 63 + file, err := root.Create(fileName) 64 + if err != nil { 65 + panic(err) 66 + } 67 + root.Remove(fileName) 68 + defer file.Close() 69 + 70 + flockErr := FileLock(file) 71 + funlockErr := FileUnlock(file) 72 + return (flockErr == nil && funlockErr == nil) 73 + } 74 + 75 + func NewFSBackend(ctx context.Context, config *FSConfig) (*FSBackend, error) { 76 blobRoot, err := maybeCreateOpenRoot(config.Root, "blob") 77 if err != nil { 78 return nil, fmt.Errorf("blob: %w", err) ··· 85 if err != nil { 86 return nil, fmt.Errorf("audit: %w", err) 87 } 88 + hasAtomicCAS := checkAtomicCAS(siteRoot) 89 + if hasAtomicCAS { 90 + logc.Println(ctx, "fs: has atomic CAS") 91 + } else { 92 + logc.Println(ctx, "fs: has best-effort CAS") 93 + } 94 + return &FSBackend{blobRoot, siteRoot, auditRoot, hasAtomicCAS}, nil 95 } 96 97 func (fs *FSBackend) Backend() Backend { ··· 251 } 252 } 253 254 + func (fs *FSBackend) HasAtomicCAS(ctx context.Context) bool { 255 + // On a suitable filesystem, POSIX advisory locks can be used to implement atomic CAS. 256 + // An implementation consists of two parts: 257 + // - Intra-process mutex set (one per manifest), to prevent races between goroutines; 258 + // - Inter-process POSIX advisory locks (one per manifest), to prevent races between 259 + // different git-pages instances. 260 + return fs.hasAtomicCAS 261 + } 262 + 263 + // Right now updates aren't very common, so this lock is essentially entirely uncontended. 264 + // If it ever becomes a bottleneck it should be replaced with a per-manifest lock. 265 + var sharedManifestLock = sync.Mutex{} 266 + 267 + type manifestLockGuard struct { 268 + file *os.File 269 + } 270 + 271 + func lockManifest(fs *os.Root, name string) (*manifestLockGuard, error) { 272 + file, err := fs.Open(name) 273 + if errors.Is(err, os.ErrNotExist) { 274 + return &manifestLockGuard{nil}, nil 275 + } else if err != nil { 276 + return nil, fmt.Errorf("open: %w", err) 277 + } 278 + if err := FileLock(file); err != nil { 279 + file.Close() 280 + return nil, fmt.Errorf("flock(LOCK_EX): %w", err) 281 + } 282 + sharedManifestLock.Lock() 283 + return &manifestLockGuard{file}, nil 284 + } 285 + 286 + func (guard *manifestLockGuard) Unlock() { 287 + if guard.file != nil { 288 + FileUnlock(guard.file) 289 + guard.file.Close() 290 + sharedManifestLock.Unlock() 291 + } 292 + } 293 + 294 + func (fs *FSBackend) checkManifestPrecondition( 295 + ctx context.Context, name string, opts ModifyManifestOptions, 296 + ) error { 297 + if !opts.IfUnmodifiedSince.IsZero() { 298 + stat, err := fs.siteRoot.Stat(name) 299 + if err != nil { 300 + return fmt.Errorf("stat: %w", err) 301 + } 302 + 303 + if stat.ModTime().Compare(opts.IfUnmodifiedSince) > 0 { 304 + return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed) 305 + } 306 + } 307 + 308 + return nil 309 + } 310 + 311 + func (fs *FSBackend) CommitManifest( 312 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 313 + ) error { 314 + if guard, err := lockManifest(fs.siteRoot, name); err != nil { 315 + return err 316 + } else { 317 + defer guard.Unlock() 318 + } 319 + 320 domain := filepath.Dir(name) 321 if err := fs.checkDomainFrozen(ctx, domain); err != nil { 322 + return err 323 + } 324 + 325 + if err := fs.checkManifestPrecondition(ctx, name, opts); err != nil { 326 return err 327 } 328 ··· 344 return nil 345 } 346 347 + func (fs *FSBackend) DeleteManifest( 348 + ctx context.Context, name string, opts ModifyManifestOptions, 349 + ) error { 350 + if guard, err := lockManifest(fs.siteRoot, name); err != nil { 351 + return err 352 + } else { 353 + defer guard.Unlock() 354 + } 355 + 356 domain := filepath.Dir(name) 357 if err := fs.checkDomainFrozen(ctx, domain); err != nil { 358 + return err 359 + } 360 + 361 + if err := fs.checkManifestPrecondition(ctx, name, opts); err != nil { 362 return err 363 } 364
+54 -3
src/backend_s3.go
··· 530 } 531 } 532 533 - func (s3 *S3Backend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 534 data := EncodeManifest(manifest) 535 logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name) 536 537 _, domain, _ := strings.Cut(name, "/") 538 if err := s3.checkDomainFrozen(ctx, domain); err != nil { 539 return err 540 } 541 ··· 547 minio.RemoveObjectOptions{}) 548 s3.siteCache.Cache.Invalidate(name) 549 if putErr != nil { 550 - return putErr 551 } else if removeErr != nil { 552 return removeErr 553 } else { ··· 555 } 556 } 557 558 - func (s3 *S3Backend) DeleteManifest(ctx context.Context, name string) error { 559 logc.Printf(ctx, "s3: delete manifest %s\n", name) 560 561 _, domain, _ := strings.Cut(name, "/") 562 if err := s3.checkDomainFrozen(ctx, domain); err != nil { 563 return err 564 } 565
··· 530 } 531 } 532 533 + func (s3 *S3Backend) HasAtomicCAS(ctx context.Context) bool { 534 + // Support for `If-Unmodified-Since:` or `If-Match:` for PutObject requests is very spotty: 535 + // - AWS supports only `If-Match:`: 536 + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html 537 + // - Minio supports `If-Match:`: 538 + // https://blog.min.io/leading-the-way-minios-conditional-write-feature-for-modern-data-workloads/ 539 + // - Tigris supports `If-Unmodified-Since:` and `If-Match:`, but only with `X-Tigris-Consistent: true`; 540 + // https://www.tigrisdata.com/docs/objects/conditionals/ 541 + // Note that the `X-Tigris-Consistent: true` header must be present on *every* transaction 542 + // touching the object, not just on the CAS transactions. 543 + // - Wasabi does not support either one and docs seem to suggest that the headers are ignored; 544 + // - Garage does not support either one and source code suggests the headers are ignored. 545 + // It seems that the only safe option is to not claim support for atomic CAS, and only do 546 + // best-effort CAS implementation using HeadObject and PutObject/DeleteObject. 547 + return false 548 + } 549 + 550 + func (s3 *S3Backend) checkManifestPrecondition( 551 + ctx context.Context, name string, opts ModifyManifestOptions, 552 + ) error { 553 + if !opts.IfUnmodifiedSince.IsZero() { 554 + stat, err := s3.client.StatObject(ctx, s3.bucket, manifestObjectName(name), 555 + minio.GetObjectOptions{}) 556 + if err != nil { 557 + return fmt.Errorf("stat: %w", err) 558 + } 559 + 560 + if stat.LastModified.Compare(opts.IfUnmodifiedSince) > 0 { 561 + return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed) 562 + } 563 + } 564 + 565 + return nil 566 + } 567 + 568 + func (s3 *S3Backend) CommitManifest( 569 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 570 + ) error { 571 data := EncodeManifest(manifest) 572 logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name) 573 574 _, domain, _ := strings.Cut(name, "/") 575 if err := s3.checkDomainFrozen(ctx, domain); err != nil { 576 + return err 577 + } 578 + 579 + if err := s3.checkManifestPrecondition(ctx, name, opts); err != nil { 580 return err 581 } 582 ··· 588 minio.RemoveObjectOptions{}) 589 s3.siteCache.Cache.Invalidate(name) 590 if putErr != nil { 591 + if errResp := minio.ToErrorResponse(putErr); errResp.Code == "PreconditionFailed" { 592 + return ErrPreconditionFailed 593 + } else { 594 + return putErr 595 + } 596 } else if removeErr != nil { 597 return removeErr 598 } else { ··· 600 } 601 } 602 603 + func (s3 *S3Backend) DeleteManifest( 604 + ctx context.Context, name string, opts ModifyManifestOptions, 605 + ) error { 606 logc.Printf(ctx, "s3: delete manifest %s\n", name) 607 608 _, domain, _ := strings.Cut(name, "/") 609 if err := s3.checkDomainFrozen(ctx, domain); err != nil { 610 + return err 611 + } 612 + 613 + if err := s3.checkManifestPrecondition(ctx, name, opts); err != nil { 614 return err 615 } 616
+1 -1
src/extract.go
··· 77 case tar.TypeDir: 78 AddDirectory(manifest, fileName) 79 default: 80 - AddProblem(manifest, fileName, "unsupported type '%c'", header.Typeflag) 81 continue 82 } 83 }
··· 77 case tar.TypeDir: 78 AddDirectory(manifest, fileName) 79 default: 80 + AddProblem(manifest, fileName, "tar: unsupported type '%c'", header.Typeflag) 81 continue 82 } 83 }
+16
src/flock_other.go
···
··· 1 + //go:build !unix 2 + 3 + package git_pages 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + ) 9 + 10 + func FileLock(file *os.File) error { 11 + return fmt.Errorf("unimplemented") 12 + } 13 + 14 + func FileUnlock(file *os.File) error { 15 + return fmt.Errorf("unimplemented") 16 + }
+16
src/flock_posix.go
···
··· 1 + //go:build unix 2 + 3 + package git_pages 4 + 5 + import ( 6 + "os" 7 + "syscall" 8 + ) 9 + 10 + func FileLock(file *os.File) error { 11 + return syscall.Flock(int(file.Fd()), syscall.LOCK_EX) 12 + } 13 + 14 + func FileUnlock(file *os.File) error { 15 + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) 16 + }
+4 -2
src/manifest.go
··· 270 271 // Uploads inline file data over certain size to the storage backend. Returns a copy of 272 // the manifest updated to refer to an external content-addressable store. 273 - func StoreManifest(ctx context.Context, name string, manifest *Manifest) (*Manifest, error) { 274 span, ctx := ObserveFunction(ctx, "StoreManifest", "manifest.name", name) 275 defer span.Finish() 276 ··· 349 return nil, err // currently ignores all but 1st error 350 } 351 352 - if err := backend.CommitManifest(ctx, name, &extManifest); err != nil { 353 if errors.Is(err, ErrDomainFrozen) { 354 return nil, err 355 } else {
··· 270 271 // Uploads inline file data over certain size to the storage backend. Returns a copy of 272 // the manifest updated to refer to an external content-addressable store. 273 + func StoreManifest( 274 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 275 + ) (*Manifest, error) { 276 span, ctx := ObserveFunction(ctx, "StoreManifest", "manifest.name", name) 277 defer span.Finish() 278 ··· 351 return nil, err // currently ignores all but 1st error 352 } 353 354 + if err := backend.CommitManifest(ctx, name, &extManifest, opts); err != nil { 355 if errors.Is(err, ErrDomainFrozen) { 356 return nil, err 357 } else {
+8 -4
src/observe.go
··· 403 return 404 } 405 406 - func (backend *observedBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) (err error) { 407 span, ctx := ObserveFunction(ctx, "CommitManifest", "manifest.name", name) 408 - err = backend.inner.CommitManifest(ctx, name, manifest) 409 span.Finish() 410 return 411 } 412 413 - func (backend *observedBackend) DeleteManifest(ctx context.Context, name string) (err error) { 414 span, ctx := ObserveFunction(ctx, "DeleteManifest", "manifest.name", name) 415 - err = backend.inner.DeleteManifest(ctx, name) 416 span.Finish() 417 return 418 }
··· 403 return 404 } 405 406 + func (backend *observedBackend) HasAtomicCAS(ctx context.Context) bool { 407 + return backend.inner.HasAtomicCAS(ctx) 408 + } 409 + 410 + func (backend *observedBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions) (err error) { 411 span, ctx := ObserveFunction(ctx, "CommitManifest", "manifest.name", name) 412 + err = backend.inner.CommitManifest(ctx, name, manifest, opts) 413 span.Finish() 414 return 415 } 416 417 + func (backend *observedBackend) DeleteManifest(ctx context.Context, name string, opts ModifyManifestOptions) (err error) { 418 span, ctx := ObserveFunction(ctx, "DeleteManifest", "manifest.name", name) 419 + err = backend.inner.DeleteManifest(ctx, name, opts) 420 span.Finish() 421 return 422 }
+92 -7
src/pages.go
··· 46 }, []string{"cause"}) 47 ) 48 49 - func reportSiteUpdate(via string, result *UpdateResult) { 50 siteUpdatesCount.With(prometheus.Labels{"via": via}).Inc() 51 - 52 switch result.outcome { 53 case UpdateError: 54 siteUpdateErrorCount.With(prometheus.Labels{"cause": "other"}).Inc() ··· 358 } 359 if !negotiatedEncoding { 360 w.WriteHeader(http.StatusNotAcceptable) 361 - return fmt.Errorf("no supported content encodings (accept-encoding: %q)", 362 r.Header.Get("Accept-Encoding")) 363 } 364 ··· 420 func putPage(w http.ResponseWriter, r *http.Request) error { 421 var result UpdateResult 422 423 host, err := GetHost(r) 424 if err != nil { 425 return err ··· 483 result = UpdateFromArchive(updateCtx, webRoot, contentType, reader) 484 } 485 486 switch result.outcome { 487 case UpdateError: 488 if errors.Is(result.err, ErrManifestTooLarge) { ··· 491 w.WriteHeader(http.StatusUnsupportedMediaType) 492 } else if errors.Is(result.err, ErrArchiveTooLarge) { 493 w.WriteHeader(http.StatusRequestEntityTooLarge) 494 } else if errors.Is(result.err, ErrDomainFrozen) { 495 w.WriteHeader(http.StatusForbidden) 496 } else { ··· 521 } else { 522 fmt.Fprintln(w, "internal error") 523 } 524 - reportSiteUpdate("rest", &result) 525 return nil 526 } 527 ··· 545 return nil 546 } 547 548 - err = backend.DeleteManifest(r.Context(), makeWebRoot(host, projectName)) 549 if err != nil { 550 w.WriteHeader(http.StatusInternalServerError) 551 } else { ··· 656 657 result := UpdateFromRepository(ctx, webRoot, repoURL, auth.branch) 658 resultChan <- result 659 - reportSiteUpdate("webhook", &result) 660 }(context.Background()) 661 662 var result UpdateResult ··· 716 } 717 } 718 } 719 - allowedMethods := []string{"OPTIONS", "HEAD", "GET", "PUT", "DELETE", "POST"} 720 if r.Method == "OPTIONS" || !slices.Contains(allowedMethods, r.Method) { 721 w.Header().Add("Allow", strings.Join(allowedMethods, ", ")) 722 } ··· 729 err = getPage(w, r) 730 case http.MethodPut: 731 err = putPage(w, r) 732 case http.MethodDelete: 733 err = deletePage(w, r) 734 // webhook API
··· 46 }, []string{"cause"}) 47 ) 48 49 + func observeSiteUpdate(via string, result *UpdateResult) { 50 siteUpdatesCount.With(prometheus.Labels{"via": via}).Inc() 51 switch result.outcome { 52 case UpdateError: 53 siteUpdateErrorCount.With(prometheus.Labels{"cause": "other"}).Inc() ··· 357 } 358 if !negotiatedEncoding { 359 w.WriteHeader(http.StatusNotAcceptable) 360 + return fmt.Errorf("no supported content encodings (Accept-Encoding: %q)", 361 r.Header.Get("Accept-Encoding")) 362 } 363 ··· 419 func putPage(w http.ResponseWriter, r *http.Request) error { 420 var result UpdateResult 421 422 + for _, header := range []string{ 423 + "If-Modified-Since", "If-Unmodified-Since", "If-Match", "If-None-Match", 424 + } { 425 + if r.Header.Get(header) != "" { 426 + http.Error(w, fmt.Sprintf("unsupported precondition %s", header), http.StatusBadRequest) 427 + return nil 428 + } 429 + } 430 + 431 host, err := GetHost(r) 432 if err != nil { 433 return err ··· 491 result = UpdateFromArchive(updateCtx, webRoot, contentType, reader) 492 } 493 494 + return reportUpdateResult(w, result) 495 + } 496 + 497 + func patchPage(w http.ResponseWriter, r *http.Request) error { 498 + if !config.Feature("patch") { 499 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 500 + return nil 501 + } 502 + 503 + for _, header := range []string{ 504 + "If-Modified-Since", "If-Unmodified-Since", "If-Match", "If-None-Match", 505 + } { 506 + if r.Header.Get(header) != "" { 507 + http.Error(w, fmt.Sprintf("unsupported precondition %s", header), http.StatusBadRequest) 508 + return nil 509 + } 510 + } 511 + 512 + host, err := GetHost(r) 513 + if err != nil { 514 + return err 515 + } 516 + 517 + projectName, err := GetProjectName(r) 518 + if err != nil { 519 + return err 520 + } 521 + 522 + webRoot := makeWebRoot(host, projectName) 523 + 524 + updateCtx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 525 + defer cancel() 526 + 527 + if _, err = AuthorizeUpdateFromArchive(r); err != nil { 528 + return err 529 + } 530 + 531 + // Providing atomic compare-and-swap operations might be difficult or impossible depending 532 + // on the backend in use and its configuration, but for applications where a mostly-atomic 533 + // compare-and-swap operation is good enough (e.g. generating page previews) we don't want 534 + // to prevent the use of partial updates. 535 + wantRaceFree := r.Header.Get("Race-Free") 536 + hasAtomicCAS := backend.HasAtomicCAS(r.Context()) 537 + switch { 538 + case wantRaceFree == "yes" && hasAtomicCAS || wantRaceFree == "no": 539 + // all good 540 + case wantRaceFree == "yes": 541 + http.Error(w, "race free partial updates unsupported", http.StatusPreconditionFailed) 542 + return nil 543 + case wantRaceFree == "": 544 + http.Error(w, "must provide \"Race-Free: yes|no\" header", http.StatusPreconditionRequired) 545 + return nil 546 + default: 547 + http.Error(w, "malformed Race-Free: header", http.StatusBadRequest) 548 + return nil 549 + } 550 + 551 + if checkDryRun(w, r) { 552 + return nil 553 + } 554 + 555 + contentType := getMediaType(r.Header.Get("Content-Type")) 556 + reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 557 + result := PartialUpdateFromArchive(updateCtx, webRoot, contentType, reader) 558 + return reportUpdateResult(w, result) 559 + } 560 + 561 + func reportUpdateResult(w http.ResponseWriter, result UpdateResult) error { 562 switch result.outcome { 563 case UpdateError: 564 if errors.Is(result.err, ErrManifestTooLarge) { ··· 567 w.WriteHeader(http.StatusUnsupportedMediaType) 568 } else if errors.Is(result.err, ErrArchiveTooLarge) { 569 w.WriteHeader(http.StatusRequestEntityTooLarge) 570 + } else if errors.Is(result.err, ErrMalformedPatch) { 571 + w.WriteHeader(http.StatusUnprocessableEntity) 572 + } else if errors.Is(result.err, ErrPreconditionFailed) { 573 + w.WriteHeader(http.StatusPreconditionFailed) 574 + } else if errors.Is(result.err, ErrWriteConflict) { 575 + w.WriteHeader(http.StatusConflict) 576 } else if errors.Is(result.err, ErrDomainFrozen) { 577 w.WriteHeader(http.StatusForbidden) 578 } else { ··· 603 } else { 604 fmt.Fprintln(w, "internal error") 605 } 606 + observeSiteUpdate("rest", &result) 607 return nil 608 } 609 ··· 627 return nil 628 } 629 630 + err = backend.DeleteManifest(r.Context(), makeWebRoot(host, projectName), 631 + ModifyManifestOptions{}) 632 if err != nil { 633 w.WriteHeader(http.StatusInternalServerError) 634 } else { ··· 739 740 result := UpdateFromRepository(ctx, webRoot, repoURL, auth.branch) 741 resultChan <- result 742 + observeSiteUpdate("webhook", &result) 743 }(context.Background()) 744 745 var result UpdateResult ··· 799 } 800 } 801 } 802 + allowedMethods := []string{"OPTIONS", "HEAD", "GET", "PUT", "PATCH", "DELETE", "POST"} 803 if r.Method == "OPTIONS" || !slices.Contains(allowedMethods, r.Method) { 804 w.Header().Add("Allow", strings.Join(allowedMethods, ", ")) 805 } ··· 812 err = getPage(w, r) 813 case http.MethodPut: 814 err = putPage(w, r) 815 + case http.MethodPatch: 816 + err = patchPage(w, r) 817 case http.MethodDelete: 818 err = deletePage(w, r) 819 // webhook API
+128
src/patch.go
···
··· 1 + package git_pages 2 + 3 + import ( 4 + "archive/tar" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "maps" 9 + "slices" 10 + "strings" 11 + ) 12 + 13 + var ErrMalformedPatch = errors.New("malformed patch") 14 + 15 + // Mutates `manifest` according to a tar stream and the following rules: 16 + // - A character device with major 0 and minor 0 is a "whiteout marker". When placed 17 + // at a given path, this path and its entire subtree (if any) are removed from the manifest. 18 + // - When a directory is placed at a given path, this path and its entire subtree (if any) are 19 + // removed from the manifest and replaced with the contents of the directory. 20 + func ApplyTarPatch(manifest *Manifest, reader io.Reader) error { 21 + type Node struct { 22 + entry *Entry 23 + children map[string]*Node 24 + } 25 + 26 + // Extract the manifest contents (which is using a flat hash map) into a directory tree 27 + // so that recursive delete operations have O(1) complexity. s 28 + var root *Node 29 + sortedNames := slices.Sorted(maps.Keys(manifest.GetContents())) 30 + for _, name := range sortedNames { 31 + entry := manifest.Contents[name] 32 + node := &Node{entry: entry} 33 + if entry.GetType() == Type_Directory { 34 + node.children = map[string]*Node{} 35 + } 36 + if name == "" { 37 + root = node 38 + } else { 39 + segments := strings.Split(name, "/") 40 + fileName := segments[len(segments)-1] 41 + iter := root 42 + for _, segment := range segments[:len(segments)-1] { 43 + if iter.children == nil { 44 + panic("malformed manifest") 45 + } else if _, exists := iter.children[segment]; !exists { 46 + panic("malformed manifest") 47 + } else { 48 + iter = iter.children[segment] 49 + } 50 + } 51 + iter.children[fileName] = node 52 + } 53 + } 54 + manifest.Contents = map[string]*Entry{} 55 + 56 + // Process the archive as a patch operation. 57 + archive := tar.NewReader(reader) 58 + for { 59 + header, err := archive.Next() 60 + if err == io.EOF { 61 + break 62 + } else if err != nil { 63 + return err 64 + } 65 + 66 + segments := strings.Split(strings.TrimRight(header.Name, "/"), "/") 67 + fileName := segments[len(segments)-1] 68 + node := root 69 + for index, segment := range segments[:len(segments)-1] { 70 + if node.children == nil { 71 + dirName := strings.Join(segments[:index], "/") 72 + return fmt.Errorf("%w: %s: not a directory", ErrMalformedPatch, dirName) 73 + } 74 + if _, exists := node.children[segment]; !exists { 75 + nodeName := strings.Join(segments[:index+1], "/") 76 + return fmt.Errorf("%w: %s: path not found", ErrMalformedPatch, nodeName) 77 + } else { 78 + node = node.children[segment] 79 + } 80 + } 81 + if node.children == nil { 82 + dirName := strings.Join(segments[:len(segments)-1], "/") 83 + return fmt.Errorf("%w: %s: not a directory", ErrMalformedPatch, dirName) 84 + } 85 + 86 + switch header.Typeflag { 87 + case tar.TypeReg: 88 + fileData, err := io.ReadAll(archive) 89 + if err != nil { 90 + return fmt.Errorf("tar: %s: %w", header.Name, err) 91 + } 92 + node.children[fileName] = &Node{ 93 + entry: NewManifestEntry(Type_InlineFile, fileData), 94 + } 95 + case tar.TypeSymlink: 96 + node.children[fileName] = &Node{ 97 + entry: NewManifestEntry(Type_Symlink, []byte(header.Linkname)), 98 + } 99 + case tar.TypeDir: 100 + node.children[fileName] = &Node{ 101 + entry: NewManifestEntry(Type_Directory, nil), 102 + children: map[string]*Node{}, 103 + } 104 + case tar.TypeChar: 105 + if header.Devmajor == 0 && header.Devminor == 0 { 106 + delete(node.children, fileName) 107 + } else { 108 + AddProblem(manifest, header.Name, 109 + "tar: unsupported chardev %d,%d", header.Devmajor, header.Devminor) 110 + } 111 + default: 112 + AddProblem(manifest, header.Name, 113 + "tar: unsupported type '%c'", header.Typeflag) 114 + continue 115 + } 116 + } 117 + 118 + // Repopulate manifest contents with the updated directory tree. 119 + var traverse func([]string, *Node) 120 + traverse = func(segments []string, node *Node) { 121 + manifest.Contents[strings.Join(segments, "/")] = node.entry 122 + for fileName, childNode := range node.children { 123 + traverse(append(segments, fileName), childNode) 124 + } 125 + } 126 + traverse([]string{}, root) 127 + return nil 128 + }
+90 -20
src/update.go
··· 6 "fmt" 7 "io" 8 "strings" 9 ) 10 11 type UpdateOutcome int ··· 25 err error 26 } 27 28 - func Update(ctx context.Context, webRoot string, manifest *Manifest) UpdateResult { 29 - var oldManifest, newManifest *Manifest 30 var err error 31 32 outcome := UpdateError 33 - oldManifest, _, _ = backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 34 - if IsManifestEmpty(manifest) { 35 - newManifest, err = manifest, backend.DeleteManifest(ctx, webRoot) 36 if err == nil { 37 if oldManifest == nil { 38 outcome = UpdateNoChange ··· 40 outcome = UpdateDeleted 41 } 42 } 43 - } else if err = PrepareManifest(ctx, manifest); err == nil { 44 - newManifest, err = StoreManifest(ctx, webRoot, manifest) 45 if err == nil { 46 domain, _, _ := strings.Cut(webRoot, "/") 47 err = backend.CreateDomain(ctx, domain) ··· 49 if err == nil { 50 if oldManifest == nil { 51 outcome = UpdateCreated 52 - } else if CompareManifest(oldManifest, newManifest) { 53 outcome = UpdateNoChange 54 } else { 55 outcome = UpdateReplaced ··· 69 case UpdateNoChange: 70 status = "unchanged" 71 } 72 - if newManifest.Commit != nil { 73 - logc.Printf(ctx, "update %s ok: %s %s", webRoot, status, *newManifest.Commit) 74 } else { 75 logc.Printf(ctx, "update %s ok: %s", webRoot, status) 76 } ··· 78 logc.Printf(ctx, "update %s err: %s", webRoot, err) 79 } 80 81 - return UpdateResult{outcome, newManifest, err} 82 } 83 84 func UpdateFromRepository( ··· 92 93 logc.Printf(ctx, "update %s: %s %s\n", webRoot, repoURL, branch) 94 95 - oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 96 // Ignore errors; worst case we have to re-fetch all of the blobs. 97 98 - manifest, err := FetchRepository(ctx, repoURL, branch, oldManifest) 99 if errors.Is(err, context.DeadlineExceeded) { 100 result = UpdateResult{UpdateTimeout, nil, fmt.Errorf("update timeout")} 101 } else if err != nil { 102 result = UpdateResult{UpdateError, nil, err} 103 } else { 104 - result = Update(ctx, webRoot, manifest) 105 } 106 107 observeUpdateResult(result) ··· 116 contentType string, 117 reader io.Reader, 118 ) (result UpdateResult) { 119 - var manifest *Manifest 120 var err error 121 122 switch contentType { 123 case "application/x-tar": 124 logc.Printf(ctx, "update %s: (tar)", webRoot) 125 - manifest, err = ExtractTar(reader) // yellow? 126 case "application/x-tar+gzip": 127 logc.Printf(ctx, "update %s: (tar.gz)", webRoot) 128 - manifest, err = ExtractGzip(reader, ExtractTar) // definitely yellow. 129 case "application/x-tar+zstd": 130 logc.Printf(ctx, "update %s: (tar.zst)", webRoot) 131 - manifest, err = ExtractZstd(reader, ExtractTar) 132 case "application/zip": 133 logc.Printf(ctx, "update %s: (zip)", webRoot) 134 - manifest, err = ExtractZip(reader) 135 default: 136 err = errArchiveFormat 137 } ··· 140 logc.Printf(ctx, "update %s err: %s", webRoot, err) 141 result = UpdateResult{UpdateError, nil, err} 142 } else { 143 - result = Update(ctx, webRoot, manifest) 144 } 145 146 observeUpdateResult(result)
··· 6 "fmt" 7 "io" 8 "strings" 9 + 10 + "google.golang.org/protobuf/proto" 11 ) 12 13 type UpdateOutcome int ··· 27 err error 28 } 29 30 + func Update( 31 + ctx context.Context, webRoot string, oldManifest, newManifest *Manifest, 32 + opts ModifyManifestOptions, 33 + ) UpdateResult { 34 var err error 35 + var storedManifest *Manifest 36 37 outcome := UpdateError 38 + if IsManifestEmpty(newManifest) { 39 + storedManifest, err = newManifest, backend.DeleteManifest(ctx, webRoot, opts) 40 if err == nil { 41 if oldManifest == nil { 42 outcome = UpdateNoChange ··· 44 outcome = UpdateDeleted 45 } 46 } 47 + } else if err = PrepareManifest(ctx, newManifest); err == nil { 48 + storedManifest, err = StoreManifest(ctx, webRoot, newManifest, opts) 49 if err == nil { 50 domain, _, _ := strings.Cut(webRoot, "/") 51 err = backend.CreateDomain(ctx, domain) ··· 53 if err == nil { 54 if oldManifest == nil { 55 outcome = UpdateCreated 56 + } else if CompareManifest(oldManifest, storedManifest) { 57 outcome = UpdateNoChange 58 } else { 59 outcome = UpdateReplaced ··· 73 case UpdateNoChange: 74 status = "unchanged" 75 } 76 + if storedManifest.Commit != nil { 77 + logc.Printf(ctx, "update %s ok: %s %s", webRoot, status, *storedManifest.Commit) 78 } else { 79 logc.Printf(ctx, "update %s ok: %s", webRoot, status) 80 } ··· 82 logc.Printf(ctx, "update %s err: %s", webRoot, err) 83 } 84 85 + return UpdateResult{outcome, storedManifest, err} 86 } 87 88 func UpdateFromRepository( ··· 96 97 logc.Printf(ctx, "update %s: %s %s\n", webRoot, repoURL, branch) 98 99 // Ignore errors; worst case we have to re-fetch all of the blobs. 100 + oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 101 102 + newManifest, err := FetchRepository(ctx, repoURL, branch, oldManifest) 103 if errors.Is(err, context.DeadlineExceeded) { 104 result = UpdateResult{UpdateTimeout, nil, fmt.Errorf("update timeout")} 105 } else if err != nil { 106 result = UpdateResult{UpdateError, nil, err} 107 } else { 108 + result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{}) 109 } 110 111 observeUpdateResult(result) ··· 120 contentType string, 121 reader io.Reader, 122 ) (result UpdateResult) { 123 var err error 124 125 + // Ignore errors; here the old manifest is used only to determine the update outcome. 126 + oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 127 + 128 + var newManifest *Manifest 129 switch contentType { 130 case "application/x-tar": 131 logc.Printf(ctx, "update %s: (tar)", webRoot) 132 + newManifest, err = ExtractTar(reader) // yellow? 133 case "application/x-tar+gzip": 134 logc.Printf(ctx, "update %s: (tar.gz)", webRoot) 135 + newManifest, err = ExtractGzip(reader, ExtractTar) // definitely yellow. 136 case "application/x-tar+zstd": 137 logc.Printf(ctx, "update %s: (tar.zst)", webRoot) 138 + newManifest, err = ExtractZstd(reader, ExtractTar) 139 case "application/zip": 140 logc.Printf(ctx, "update %s: (zip)", webRoot) 141 + newManifest, err = ExtractZip(reader) 142 default: 143 err = errArchiveFormat 144 } ··· 147 logc.Printf(ctx, "update %s err: %s", webRoot, err) 148 result = UpdateResult{UpdateError, nil, err} 149 } else { 150 + result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{}) 151 + } 152 + 153 + observeUpdateResult(result) 154 + return 155 + } 156 + 157 + func PartialUpdateFromArchive( 158 + ctx context.Context, 159 + webRoot string, 160 + contentType string, 161 + reader io.Reader, 162 + ) (result UpdateResult) { 163 + var err error 164 + 165 + // Here the old manifest is used both as a substrate to which a patch is applied, as well 166 + // as a "load linked" operation for a future "store conditional" update which, taken together, 167 + // create an atomic compare-and-swap operation. 168 + oldManifest, oldManifestMtime, err := backend.GetManifest(ctx, webRoot, 169 + GetManifestOptions{BypassCache: true}) 170 + if err != nil { 171 + logc.Printf(ctx, "patch %s err: %s", webRoot, err) 172 + return UpdateResult{UpdateError, nil, err} 173 + } 174 + 175 + applyTarPatch := func(reader io.Reader) (*Manifest, error) { 176 + // Clone the manifest before starting to mutate it. `GetManifest` may return cached 177 + // `*Manifest` objects, which should never be mutated. 178 + newManifest := &Manifest{} 179 + proto.Merge(newManifest, oldManifest) 180 + if err := ApplyTarPatch(newManifest, reader); err != nil { 181 + return nil, err 182 + } else { 183 + return newManifest, nil 184 + } 185 + } 186 + 187 + var newManifest *Manifest 188 + switch contentType { 189 + case "application/x-tar": 190 + logc.Printf(ctx, "patch %s: (tar)", webRoot) 191 + newManifest, err = applyTarPatch(reader) 192 + case "application/x-tar+gzip": 193 + logc.Printf(ctx, "patch %s: (tar.gz)", webRoot) 194 + newManifest, err = ExtractGzip(reader, applyTarPatch) 195 + case "application/x-tar+zstd": 196 + logc.Printf(ctx, "patch %s: (tar.zst)", webRoot) 197 + newManifest, err = ExtractZstd(reader, applyTarPatch) 198 + default: 199 + err = errArchiveFormat 200 + } 201 + 202 + if err != nil { 203 + logc.Printf(ctx, "patch %s err: %s", webRoot, err) 204 + result = UpdateResult{UpdateError, nil, err} 205 + } else { 206 + result = Update(ctx, webRoot, oldManifest, newManifest, 207 + ModifyManifestOptions{IfUnmodifiedSince: oldManifestMtime}) 208 + // The `If-Unmodified-Since` precondition is internally generated here, which means its 209 + // failure shouldn't be surfaced as-is in the HTTP response. If we also accepted options 210 + // from the client, then that precondition failure should surface in the response. 211 + if errors.Is(result.err, ErrPreconditionFailed) { 212 + result.err = ErrWriteConflict 213 + } 214 } 215 216 observeUpdateResult(result)
+112
test/stresspatch/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "archive/tar" 5 + "bytes" 6 + "flag" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "sync" 11 + "time" 12 + ) 13 + 14 + func makeInit() []byte { 15 + writer := bytes.NewBuffer(nil) 16 + archive := tar.NewWriter(writer) 17 + archive.WriteHeader(&tar.Header{ 18 + Typeflag: tar.TypeReg, 19 + Name: "index.html", 20 + }) 21 + archive.Write([]byte{}) 22 + archive.Flush() 23 + return writer.Bytes() 24 + } 25 + 26 + func initSite() { 27 + req, err := http.NewRequest(http.MethodPut, "http://localhost:3000", 28 + bytes.NewReader(makeInit())) 29 + if err != nil { 30 + panic(err) 31 + } 32 + 33 + req.Header.Add("Content-Type", "application/x-tar") 34 + resp, err := http.DefaultClient.Do(req) 35 + if err != nil { 36 + panic(err) 37 + } 38 + defer resp.Body.Close() 39 + } 40 + 41 + func makePatch(n int) []byte { 42 + writer := bytes.NewBuffer(nil) 43 + archive := tar.NewWriter(writer) 44 + archive.WriteHeader(&tar.Header{ 45 + Typeflag: tar.TypeReg, 46 + Name: fmt.Sprintf("%d.txt", n), 47 + }) 48 + archive.Write([]byte{}) 49 + archive.Flush() 50 + return writer.Bytes() 51 + } 52 + 53 + func patchRequest(n int) int { 54 + req, err := http.NewRequest(http.MethodPatch, "http://localhost:3000", 55 + bytes.NewReader(makePatch(n))) 56 + if err != nil { 57 + panic(err) 58 + } 59 + 60 + req.Header.Add("Race-Free", "no") 61 + req.Header.Add("Content-Type", "application/x-tar") 62 + resp, err := http.DefaultClient.Do(req) 63 + if err != nil { 64 + panic(err) 65 + } 66 + defer resp.Body.Close() 67 + 68 + data, err := io.ReadAll(resp.Body) 69 + if err != nil { 70 + panic(err) 71 + } 72 + 73 + fmt.Printf("%d: %s %q\n", n, resp.Status, string(data)) 74 + return resp.StatusCode 75 + } 76 + 77 + func concurrentWriter(wg *sync.WaitGroup, n int) { 78 + for { 79 + if patchRequest(n) == 200 { 80 + break 81 + } 82 + } 83 + wg.Done() 84 + } 85 + 86 + var count = flag.Int("count", 10, "request count") 87 + 88 + func main() { 89 + flag.Parse() 90 + 91 + initSite() 92 + time.Sleep(time.Second) 93 + 94 + wg := &sync.WaitGroup{} 95 + for n := range *count { 96 + wg.Add(1) 97 + go concurrentWriter(wg, n) 98 + } 99 + wg.Wait() 100 + 101 + success := 0 102 + for n := range *count { 103 + resp, err := http.Get(fmt.Sprintf("http://localhost:3000/%d.txt", n)) 104 + if err != nil { 105 + panic(err) 106 + } 107 + if resp.StatusCode == 200 { 108 + success++ 109 + } 110 + } 111 + fmt.Printf("written: %d of %d\n", success, *count) 112 + }