From 4a2861a46801d614e5e536c32cce615cfc0c9e2c Mon Sep 17 00:00:00 2001 From: James Ranson Date: Sun, 20 Sep 2026 21:07:57 -0600 Subject: [PATCH 1/2] add static file server Signed-off-by: James Ranson --- README.md | 5 +- deploy/kube/configmap.yaml | 66 +- docs/metrics.md | 31 + docs/static.md | 242 +++ docs/supported-backend-providers.md | 4 + examples/conf/example.full.yaml | 66 +- integration/static_test.go | 469 +++++ integration/testdata/configs/static.yaml | 58 + pkg/backends/alb/pool/target.go | 4 +- pkg/backends/backends.go | 18 +- pkg/backends/backends_test.go | 23 + pkg/backends/backends_virtual_test.go | 1 + pkg/backends/options/errors.go | 25 + pkg/backends/options/options.go | 56 + pkg/backends/options/options_static_test.go | 166 ++ pkg/backends/providers/providers.go | 14 +- pkg/backends/providers/providers_test.go | 12 + pkg/backends/providers/registry/registry.go | 2 + pkg/backends/static/cache.go | 640 +++++++ pkg/backends/static/cache_test.go | 802 +++++++++ pkg/backends/static/client.go | 116 ++ pkg/backends/static/client_test.go | 115 ++ pkg/backends/static/encoding.go | 108 ++ pkg/backends/static/middleware.go | 163 ++ pkg/backends/static/middleware_test.go | 131 ++ pkg/backends/static/mime.go | 177 ++ pkg/backends/static/mime_test.go | 59 + pkg/backends/static/options/defaults.go | 32 + pkg/backends/static/options/options.go | 268 +++ pkg/backends/static/options/options_test.go | 221 +++ pkg/backends/static/series.go | 103 ++ pkg/backends/static/series_test.go | 192 ++ pkg/backends/static/server.go | 759 ++++++++ pkg/backends/static/server_test.go | 1740 +++++++++++++++++++ pkg/backends/static/server_unix_test.go | 45 + pkg/daemon/daemon.go | 2 + pkg/daemon/setup/setup.go | 4 + pkg/daemon/setup/shutdown_test.go | 22 + pkg/encoding/handler/handler.go | 7 +- pkg/encoding/handler/handler_test.go | 46 + pkg/encoding/profile/profile.go | 10 + pkg/encoding/profile/profile_test.go | 27 + pkg/encoding/providers/accept.go | 206 +++ pkg/encoding/providers/accept_test.go | 155 ++ pkg/encoding/providers/providers.go | 29 +- pkg/observability/keys/keys.go | 2 + pkg/observability/metrics/metrics.go | 102 +- pkg/proxy/handlers/handlers.go | 4 +- pkg/proxy/handlers/handlers_test.go | 2 +- pkg/proxy/headers/headers.go | 2 + pkg/watchers/filesystem/dirs.go | 378 ++++ pkg/watchers/filesystem/dirs_test.go | 599 +++++++ testdata/test.full.02.conf | 2 +- testdata/test.full.conf | 2 +- testdata/test.full.tls.conf | 2 +- testdata/test.invalid-pcf-name.conf | 2 +- 56 files changed, 8466 insertions(+), 72 deletions(-) create mode 100644 docs/static.md create mode 100644 integration/static_test.go create mode 100644 integration/testdata/configs/static.yaml create mode 100644 pkg/backends/options/options_static_test.go create mode 100644 pkg/backends/static/cache.go create mode 100644 pkg/backends/static/cache_test.go create mode 100644 pkg/backends/static/client.go create mode 100644 pkg/backends/static/client_test.go create mode 100644 pkg/backends/static/encoding.go create mode 100644 pkg/backends/static/middleware.go create mode 100644 pkg/backends/static/middleware_test.go create mode 100644 pkg/backends/static/mime.go create mode 100644 pkg/backends/static/mime_test.go create mode 100644 pkg/backends/static/options/defaults.go create mode 100644 pkg/backends/static/options/options.go create mode 100644 pkg/backends/static/options/options_test.go create mode 100644 pkg/backends/static/series.go create mode 100644 pkg/backends/static/series_test.go create mode 100644 pkg/backends/static/server.go create mode 100644 pkg/backends/static/server_test.go create mode 100644 pkg/backends/static/server_unix_test.go create mode 100644 pkg/encoding/providers/accept.go create mode 100644 pkg/encoding/providers/accept_test.go create mode 100644 pkg/watchers/filesystem/dirs.go create mode 100644 pkg/watchers/filesystem/dirs_test.go diff --git a/README.md b/README.md index cd6bf3913..cd27e27e8 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,11 @@ Trickster is a fully-featured Reverse Proxy Cache for HTTP applications like sta * Built-in Prometheus [metrics](./docs/metrics.md) and customizable [Health Check](./docs/health.md) Endpoints for end-to-end monitoring * [Negative Caching](./docs/negative-caching.md) to prevent domino effect outages * High-performance [Collapsed Forwarding](./docs/collapsed-forwarding.md) -* Best-in-class [Byte Range Request caching and acceleration](./docs/range_request.md). -* [Distributed Tracing](./docs/tracing.md) via OpenTelemetry, supporting OTLP protocol. +* Best-in-class [Byte Range Request caching and acceleration](./docs/range_request.md) +* [Distributed Tracing](./docs/tracing.md) via OpenTelemetry, supporting OTLP protocol * Per-backend [Access and Error Logs](./docs/access-logs.md) with Apache-style customizable formats * Rules engine for custom request routing and rewriting +* Built-in [Static File Server](./docs/static.md) for hosting websites and other local content ## Time Series Database Accelerator diff --git a/deploy/kube/configmap.yaml b/deploy/kube/configmap.yaml index 555c604b6..df4bc259a 100644 --- a/deploy/kube/configmap.yaml +++ b/deploy/kube/configmap.yaml @@ -375,6 +375,60 @@ data: # Configuration options for mapping Origin(s) backends: + # # example static file server backend, which serves a local directory rather + # # than proxying to an origin. paths, req_rewriter_name, origin_url and other + # # proxying options are not valid for this provider. + # website: + # provider: static + # hosts: [ www.example.com ] + # # authenticator_name optionally requires users to authenticate before any file is served + # # authenticator_name: example_auth_1 + # # the static block, and its root, are required when the provider is static + # static: + # # root is the directory holding the content to serve. it must exist + # root: /var/www/html + # # default_file is served when a directory is requested. default is index.html + # default_file: index.html + # # cache_control is the Cache-Control header sent with every file. by default none is + # # sent, and clients judge a file's freshness from its Last-Modified time + # cache_control: no-cache + # # cache_control_by_extension overrides cache_control for matching files + # cache_control_by_extension: + # .js: public, max-age=31536000, immutable + # .css: public, max-age=31536000, immutable + # # response_headers are attached to every response + # response_headers: + # X-Content-Type-Options: nosniff + # # mime_types adds to and overrides the built-in Content-Types by file extension + # mime_types: + # .md: text/markdown; charset=utf-8 + # # not_found_file is a file within the root served in place of a plain 404 response. + # # not_found_status is the status it is served with: 404 (the default) for an error + # # page, or 200 for a single-page application whose routes exist only in the browser + # not_found_file: errors/404.html + # not_found_status: 404 + # # directory_listing lists a directory that has no default_file, rather than + # # answering it with a 404. default is false + # directory_listing: false + # # cache configures the fileserver cache, which holds small files in memory and drops + # # them when they change on disk. it is separate from, and unrelated to, the caches + # # configured in the caches section, and a static backend does not use a cache_name + # cache: + # # disabled serves every request from disk. default is false + # disabled: false + # # max_file_size_bytes is the largest file held in memory. default is 1048576 (1 MiB) + # max_file_size_bytes: 1048576 + # # max_size_bytes is the most memory the cache will use, counting each held file + # # as its size plus 1 KiB of bookkeeping. default is 134217728 (128 MiB) + # max_size_bytes: 134217728 + # # max_files is the most objects held (a file, and each compressed rendition of it, is + # # one), which also bounds the directories watched. the least recently used make room + # # for new ones once this or max_size_bytes is reached. default is 10000 + # max_files: 10000 + # # revalidation_interval is how often held files are compared to disk, as a + # # backstop to filesystem change events. default is 10s + # revalidation_interval: 10s + # # example mysql backend, exposed by a listener with protocol mysql (see the # # listeners section). MySQL client connections to the listener are proxied # # to the origin. Exactly one mysql backend may map to a mysql listener. @@ -673,11 +727,15 @@ data: # # The default is false. # multipart_ranges_disabled: false - # # compressable_types defines the Content Types that will be compressed when stored in the Trickster cache - # # reasonable defaults are set, so use this with care. To disable compression, set compressable_types: [] + # # compressible_types defines the Content Types that will be compressed when stored in the Trickster cache + # # or sent to a client. reasonable defaults are set, so use this with care. To disable compression, set + # # compressible_types: [] + # # The encoding used is the one the client's Accept-Encoding header weights highest (q) of those + # # Trickster supports (zstd, br, gzip, deflate). Among equal weights, or when the client gives none, + # # Trickster prefers them in that order. A wildcard (*) stands for the encodings the client didn't + # # name. An encoding the client refuses (q=0), or weights below identity, is never used. # # Default list is provided here: - # compressable_types: - # - text/javascript, text/css, text/plain, text/xml, text/json, application/json, application/javascript, application/xml ] + # compressible_types: [ text/javascript, text/css, text/plain, text/xml, text/json, application/json, application/javascript, application/xml ] # # timeout defines how long Trickster will wait before aborting an upstream http request. Default: 60s # timeout: 60s diff --git a/docs/metrics.md b/docs/metrics.md index 646167080..cab1635c5 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -282,6 +282,37 @@ The following metrics are available only for Caches Types whose object lifecycle * `cache_name` - the name of the configured cache$ * `provider` - the type of the configured cache +The following metrics are available for [Static File Server](./static.md) Backends. Requests they serve are also counted, like those of any other Backend, by the `trickster_frontend_requests_*` metrics with a `provider` of `static`. Their Fileserver cache is separate from the caches above, and is not reported by the `trickster_cache_*` metrics. + +* `trickster_fileserver_responses_total` (Counter) - The total number of files served, by how the Fileserver cache figured in the response. Responses that send no file (such as a `404` with no not-found file, a redirect or a directory listing) are not counted. + * labels: + * `backend_name` - the name of the configured backend + * `cache_status` - `hit` (served as it was held), `phit` (the file was held, and was encoded for the response and the rendition then held), `kmiss` (read from disk for the response, and then held) or `disk` (sent from disk without being held, as for a large file, a byte range, a `HEAD` or a `304`) + * `encoding` - the encoding of the rendition the file server sent: `identity`, `zstd`, `br`, `gzip` or `deflate`. A response counted as `identity` may still be encoded on its way out, as a large compressible file is. + +* `trickster_fileserver_cache_events_total` (Counter) - The total number of objects removed from the Fileserver cache. + * labels: + * `backend_name` - the name of the configured backend + * `event` - `eviction` (the least recently used, removed to make room) or `invalidation` (removed because the file changed on disk, or the cache was stopped) + +A backend's series are published only once it is in service, so a configuration that is rejected publishes nothing. They are deleted when a reload removes or renames the backend; across a reload that keeps its name, the counters carry on rather than start over. The four gauges that follow are published only while the backend has a Fileserver cache, and are removed when it is disabled. + +* `trickster_fileserver_cache_usage_objects` (Gauge) - The current count of objects in the Fileserver cache, including files being read into it. Each held rendition of a file is an object. + * labels: + * `backend_name` - the name of the configured backend + +* `trickster_fileserver_cache_usage_bytes` (Gauge) - The current accounted size of the Fileserver cache in bytes, which includes each object's bookkeeping allowance. + * labels: + * `backend_name` - the name of the configured backend + +* `trickster_fileserver_cache_max_usage_objects` (Gauge) - The configured `max_files` of the Fileserver cache. + * labels: + * `backend_name` - the name of the configured backend + +* `trickster_fileserver_cache_max_usage_bytes` (Gauge) - The configured `max_size_bytes` of the Fileserver cache. + * labels: + * `backend_name` - the name of the configured backend + The following metrics are available when the Kubernetes Gateway/Ingress controller is enabled (the top-level `kubernetes` section; see [kubernetes-gateway.md](./kubernetes-gateway.md)): * `trickster_kgw_reconciles_total` (Counter) - Count of controller reconcile passes, by result diff --git a/docs/static.md b/docs/static.md new file mode 100644 index 000000000..51add248c --- /dev/null +++ b/docs/static.md @@ -0,0 +1,242 @@ +# Static File Server Backend + +The Static Backend serves the contents of a local directory, rather than proxying requests to an upstream origin. Use it to host a website, a single-page application's assets, a maintenance page, or any other set of files directly from Trickster, alongside (and with the same listeners, TLS, virtual hosting, authentication, logging and tracing as) your other Backends. + +The file server is built into Trickster on the Go standard library, and provides: + +- `GET` and `HEAD` for files, with `ETag` and `Last-Modified` validators, conditional requests (`If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`), and single, multi-part and `If-Range` byte range requests +- a configurable default file (`index.html`) for directory requests +- `Content-Type` detection from a built-in table of common web types, with your own additions and overrides +- a configurable `Cache-Control` header, with overrides by file extension +- a fileserver cache that holds small files in memory, kept current by filesystem change events, with the least recently used making room when it fills +- compression for the Backend's `compressible_types`, with each encoded rendition of a small file held in memory and reused +- a not-found file, for a custom error page or a single-page application +- [Prometheus metrics](./metrics.md) for the files served and the cache +- safe defaults: dotfiles are never served (other than `/.well-known/`), directory listings are off, and no request can reach a file outside of the configured root + +## Configuring + +A Static Backend sets its `provider` to `static`, and requires a `static` options block that names the `root` directory to serve. + +```yaml +backends: + website: + provider: static + hosts: [ www.example.com ] + static: + root: /var/www/html +``` + +Like any other Backend, this one is reachable at `/website/` on its listeners, at `/` for requests whose `Host` is `www.example.com`, and at `/` for every request when it sets `is_default: true`. + +### Static Options + +| Option | Default | Description | +| --- | --- | --- | +| `root` | *(required)* | Path to the directory holding the content to serve. It must exist and be a directory when the configuration is loaded. A relative path is resolved from Trickster's working directory. | +| `default_file` | `index.html` | The file served when a directory is requested (e.g., `/` or `/docs/`). It must be a plain file name that does not start with a `.` | +| `cache_control` | | The `Cache-Control` header value sent with every file. By default no `Cache-Control` header is sent, as with nginx and other common web servers, and a client judges how long a file stays fresh from its `Last-Modified` time. | +| `cache_control_by_extension` | | A map of file extension to `Cache-Control` value, which takes precedence over `cache_control` for matching files. | +| `response_headers` | | A map of additional headers to attach to every response. | +| `mime_types` | | A map of file extension to `Content-Type`, which adds to and takes precedence over the built-in types. | +| `not_found_file` | | A file, as a path within the root, that is served in place of a plain `404` response. See [Not-Found File](#not-found-file). | +| `not_found_status` | `404` | The status `not_found_file` is served with: `404` for an error page, or `200` for a single-page application. | +| `directory_listing` | `false` | When `true`, a directory that has no `default_file` is answered with a listing of its contents. | +| `cache` | | Options for the Fileserver cache, which holds small files in memory, described [below](#fileserver-cache). | + +File extensions are case-insensitive, and may be written with or without the leading dot. + +A fuller example: + +```yaml +backends: + website: + provider: static + is_default: true + static: + root: /var/www/html + default_file: index.html + # html is revalidated on every use, so a deployment is visible immediately + cache_control: no-cache + cache_control_by_extension: + # fingerprinted assets never change, and can be cached for a year + .js: public, max-age=31536000, immutable + .css: public, max-age=31536000, immutable + .woff2: public, max-age=31536000, immutable + response_headers: + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + mime_types: + .md: text/markdown; charset=utf-8 + not_found_file: errors/404.html + directory_listing: false + cache: + max_file_size_bytes: 1048576 + max_size_bytes: 134217728 + max_files: 10000 + revalidation_interval: 10s +``` + +### Backend Options + +These general Backend options work with a Static Backend as they do with any other: `hosts`, `any_host_routing`, `listener_names`, `is_default`, `path_routing_disabled`, `require_tls`, `tls`, `authenticator_name`, `cors`, `access_log`, `tracing_name`, `compressible_types`, `latency_min` and `latency_max`. + +Options that describe proxying, caching or routing to an origin have no meaning for a file server. Rather than being silently ignored, these fail configuration validation when set on a Static Backend: `paths`, `req_rewriter_name`, `origin_url`, `rule_name`, `alb`, `prometheus`, `mysql`, `graphite`, `influxdb`, `sigv4`, `protocol`, `h2c_prior_knowledge`, `preserve_host`, `proxy_only` and `is_template`. Likewise, a `static` block on a Backend of any other provider fails validation. + +A Static Backend has no origin to probe, so it always reports as available on the [health](./health.md) endpoint. It can be the target of a [Rule](./rule.md) or a member of an [ALB](./alb.md) pool. + +## Requiring Authentication + +To require users to authenticate before any file is served, attach an [Authenticator](./authenticator.md) to the Backend. Requests that fail authentication are rejected before the file server is consulted, so they reveal nothing about which files exist. + +```yaml +authenticators: + staff: + provider: basic + users_file: /etc/trickster/htpasswd + users_file_format: htpasswd + +backends: + intranet: + provider: static + authenticator_name: staff + static: + root: /var/www/intranet +``` + +## Request Handling + +| Request | Response | +| --- | --- | +| a file | `200 OK` with the file | +| a directory, with a trailing slash | `200 OK` with the directory's `default_file` | +| a directory with a trailing slash, but no `default_file` | `404 Not Found`, or a listing when `directory_listing` is `true` | +| a directory, without a trailing slash | `301 Moved Permanently` to the same path with a trailing slash | +| a file, with a trailing slash | `404 Not Found` | +| any path with a segment that starts with a `.`, other than a leading `/.well-known/` | `404 Not Found` | +| anything that does not exist, can't be read, or resolves outside of the root | `404 Not Found` | +| `OPTIONS` | `204 No Content` with an `Allow` header | +| any method other than `GET`, `HEAD` or `OPTIONS` | `405 Method Not Allowed` with an `Allow` header | + +Every `404 Not Found` in this table is answered with the [not-found file](#not-found-file) when one is configured. + +A missing `default_file` is deliberately a `404` rather than a `401` or `403`: with listings off, a directory is not a resource that exists to be forbidden. + +The trailing-slash redirect is relative (`Location: docs/`), so it is correct whether the Backend was reached by its hostname, as the default Backend, or under its `/backend-name/` path. + +### Dotfiles + +Any request path containing a segment that begins with a `.` is answered with a `404`. This covers hidden files (`/.env`, `/.htpasswd`), everything beneath hidden directories (`/.git/config`), and attempts to reach them indirectly (`/docs/../.env`). Dotfiles are also left out of directory listings. + +The one exception is `/.well-known/`, the location reserved by [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) for site-wide metadata such as `security.txt`, ACME HTTP-01 challenges and app-association files. It is exempt only as the first segment of the path (`/docs/.well-known/` is refused), dotfiles within it are still refused (`/.well-known/.secret`), and it is still left out of directory listings. The list of exemptions is built in, and is deliberately not configurable. + +### Not-Found File + +`not_found_file` names a file within the root to serve whenever the response would otherwise be a plain `404`: a path that doesn't exist, a directory with no `default_file`, a refused dotfile, and so on. `not_found_status` selects which of its two uses it is put to. + +With the default status of `404`, it is a custom error page. The page is sent as the body of the `404`, with `Cache-Control: no-cache` and without the `ETag`, `Last-Modified` and `Accept-Ranges` headers, so that it can't be revalidated, ranged over or reused as though it were the missing file. Any `Range` or conditional headers on the request are ignored. + +```yaml + static: + root: /var/www/html + not_found_file: errors/404.html +``` + +With a status of `200`, it is the fallback for a single-page application, whose routes (`/dashboard`, `/users/42/edit`) exist only in the browser. Every unknown path is answered with the application itself, exactly as if the file had been requested by its own path: with its validators, its `Cache-Control`, conditional and byte range support, and its held renditions. Requests for files that do exist are unaffected. + +```yaml + static: + root: /var/www/app + not_found_file: index.html + not_found_status: 200 +``` + +If the not-found file is itself missing, the response is a plain `404`. A request using a method other than `GET` or `HEAD` is never answered with it. When `directory_listing` is on, a directory that can be listed is listed rather than answered with it. + +### Root Confinement and Symbolic Links + +All file access is confined to the `root` by the operating system, not by path inspection alone. No request can read a file outside of the root, whether by `..` traversal or through a symbolic link. + +Symbolic links are followed only when they are *relative* links to a target *inside* the root. A link to anywhere outside of the root, and any link with an absolute target (even one that points inside the root), is answered with a `404`. + +Only regular files are served. Devices, sockets and named pipes inside the root are answered with a `404`. + +### Content Types + +A file's `Content-Type` is resolved from, in order: the `mime_types` option, Trickster's built-in table of common web types (HTML, CSS, JavaScript, JSON, WebAssembly, images, fonts, audio, video, archives, documents and more), and the host's MIME database. The built-in table makes detection work the same in a minimal container image as on a full host. A file whose extension is unknown, or that has none, is identified from its first 512 bytes, and falls back to `application/octet-stream`. + +### Validators, Ranges and Compression + +Every file is served with an `ETag` and a `Last-Modified` header, and matching conditional requests are answered with a `304 Not Modified`. + +Unless `cache_control` is set, no `Cache-Control` header is sent. A client then decides for itself how long to reuse a file before revalidating it, usually for a fraction of the time since the file was last modified, so files that have not changed in a long time are reused for longer. Where a change must be seen at once, such as the HTML entry point of an application with fingerprinted assets, set `cache_control` (or `cache_control_by_extension`) to `no-cache`, which has clients revalidate the file before each reuse. + +The `ETag` is derived from the file's modification time and size, the same two components used by nginx and other common web servers. Because it comes from file metadata, the `ETag` is the same whether a file is served from memory or from disk, and a `HEAD` or conditional request is answered from a `stat` of the file without reading it. Instances of Trickster serving copies of the same content issue the same `ETag` for a file, so long as the deployment preserves modification times (as `rsync -a` and `tar` do). + +As with those servers, a file that is replaced by different content of exactly the same size and modification time keeps its `ETag`. If your deployment tooling pins or preserves modification times, give changed files a new name (such as a content fingerprint) or a new modification time. + +Files whose `Content-Type` is in the Backend's `compressible_types` are compressed for clients that accept it, in any of `zstd`, `br`, `gzip` and `deflate`. Those responses carry `Vary: Accept-Encoding`, and the weak form of the `ETag` (`W/"..."`), because the encoded bytes differ from the stored file the `ETag` describes. Byte ranges always address the stored file, so a `206 Partial Content` response is never compressed, and carries the `ETag` unchanged. + +How a file is compressed depends on whether the [Fileserver cache](#fileserver-cache) can hold it. A file it can hold is compressed once, and the encoded *rendition* is held and reused, as described under [Held Renditions](#held-renditions). Any other file (a larger one, or any file when the cache is disabled) is compressed as it is sent, on every request. + +## Fileserver Cache + +The Fileserver cache is configured in the `cache` block of the `static` options, and belongs to the Static Backend alone. It is unrelated to the [caches](./caches.md) Trickster uses to accelerate proxied Backends (including the `memory` cache provider): it is not configured in the `caches` section, a Static Backend has no `cache_name`, and nothing it holds is reachable through the cache purge endpoints. + +Small files are read into memory the first time their content is needed, and served from memory afterward without touching the disk. Larger files are streamed from disk on every request. + +Only a response that sends the whole file loads it. A `HEAD` request, a conditional request answered with a `304`, and a byte range request are all served from disk without reading the file into memory, so they can't be used to fill the cache. When many requests arrive at once for a file that is not yet held, it is read once and shared between them. + +| Option | Default | Description | +| --- | --- | --- | +| `disabled` | `false` | When `true`, every request is served from disk. | +| `max_file_size_bytes` | `1048576` (1 MiB) | The largest file that is held in memory. | +| `max_size_bytes` | `134217728` (128 MiB) | The most memory the cache will use. Each held file counts as its size plus a fixed 1 KiB allowance for its bookkeeping, so empty and tiny files are bounded too. | +| `max_files` | `10000` | The most objects that are held, which also bounds the number of directories that are watched. A file as stored, and each held rendition of it, is an object. | +| `revalidation_interval` | `10s` | How often held files are compared to the disk, as a backstop to change events. | + +Capacity is reserved before a file is read, so the limits hold even while many different files are being loaded at once. Once either limit is reached, the [least recently used](#eviction) objects make room for new ones. + +### Eviction + +When a file needs room that the cache doesn't have, by either limit, the least recently used objects are evicted to make it. Recency is tracked without slowing requests down: serving a held file only marks it as used, and eviction passes over each marked object once, clearing its mark, before it will remove it. An object is therefore evicted only if it has gone unread since eviction last reached it. + +Room is only ever made for a file that is then held. Eviction first works out what would have to go, and goes ahead only if that is enough; a file that there isn't room for is served from disk, and costs the cache nothing it holds. A request for a file that is never asked for again therefore can't flush content that is in use. + +The work a request will do to find room is bounded, however large the cache is: it passes over a limited number of recently used objects before giving up. Those it passed are no longer marked as recently used, so if requests for room keep coming, objects that aren't being read in between give way to them, while objects in constant use never do. The objects a request does evict are in proportion to the size of the file they make way for. No single file is admitted at the cost of more than 128 objects. With the default limits that is rarely a constraint, as a full cache is usually full by its count of objects, and one eviction then makes room for a file of any size; it matters to a cache with a very high `max_files` that is full of very small files, in which a large file is served from disk instead. + +A file is never evicted to make room for a rendition of itself, as a rendition is only held alongside the file it was made from. Where there is room for only one of them, it is the file that is held, and its renditions are made from memory for each request. Room held by files that are still being read into memory can't be evicted either; in the unlikely event that those account for the whole cache, a new file is served from disk. + +### Held Renditions + +A held file of a compressible type is also held in the encodings clients ask for, each as an object of its own beside the file as stored, so that a file is compressed once rather than on every request. + +A request's `Accept-Encoding` header is read for the encodings Trickster supports (`zstd`, `br`, `gzip` and `deflate`), most preferred first: by the weight (`q`) the client gave each, and among encodings of equal weight, or when no weights are given (as by browsers), by Trickster's own preference in the order just listed. An encoding takes the weight of its own entry, or else that of the wildcard (`*`) if there is one. Encodings the client refused (`q=0`) are left out, and so are any it weighted below `identity`, since the file as stored is always there to be sent instead. Then: + +1. If the file is held in any of those encodings, the most preferred of the renditions that are held is served. +2. Otherwise, if the file is held as stored, it is encoded from memory in the client's most preferred encoding, and the new rendition is held. +3. Otherwise, the file is read from disk and encoded in the client's most preferred encoding, and both the file as stored and the new rendition are held. A rendition that is held in some other encoding is never decoded to stand in for the file. + +A new rendition is streamed to the client as it is encoded, and what was sent is kept to be held, so the first response is not delayed by the rendition being made for later ones. That first response is sent without a `Content-Length`; once held, a rendition is sent with its own. + +Renditions count toward `max_size_bytes` and `max_files` like any other object, are evicted independently of one another, and are all dropped together when their file changes on disk. A rendition is only made for a response that sends the whole file: not for a `HEAD`, a byte range, or a conditional request (which may turn out to need no body; if it does need one, it is compressed as it is sent). Files smaller than 512 bytes are always sent as stored, as an encoding's own framing outweighs what it would save. When an encoding turns out not to make a file smaller, that encoding isn't tried for the file again until it changes; the other encodings are unaffected, and a client that prefers the unhelpful one is given the next it accepts. + +When nothing a client accepts is left (for example `identity;q=0` from a client that accepts no supported encoding), the file is sent as stored rather than refused with a `406`, as other web servers do. + +### Serving and the Cache + +Holding a file never delays serving it. Held files are read without locking. Everything that changes the cache (holding a file or a rendition, or releasing room that turned out not to be needed) is done after, and apart from, the response that led to it. The one thing a request asks of the cache before it responds is to reserve room, which is what keeps memory within its limits however many files are being read at once; if the cache is busy at that moment, the request doesn't wait, and is served without being held. The file is then held by a later request for it. + +### Change Detection + +Trickster watches the directories of the files it holds, and stops watching a directory soon after it holds nothing from it. Letting go of a watch is done in the background, so evicting files never makes a request wait on the operating system. When a file is modified, replaced, renamed or removed, it is dropped from memory (typically within milliseconds) and the next request reads it from disk again. Renaming or removing a directory drops every held file beneath it. A changed file is never updated in place, and a read that overlaps a change is discarded rather than stored, so a request is never served a mix of old and new content from memory. A change touches only what it affects: the one file, or the files beneath the one directory. Every other held file stays in memory, and files that are being read into memory at that moment are unaffected, so a busy deployment does not push the rest of the site back to disk. + +Every `revalidation_interval`, each held file's size, modification time and identity are compared to the disk, which catches changes that raise no event: a filesystem without change notifications (such as some network and container bind mounts), a changed symbolic link target, or a replaced root. On such filesystems, `revalidation_interval` is the longest a stale file can be served; lower it, or disable the fileserver cache, if that matters for your content. + +### Atomic Deployments + +Deploying by swapping a symbolic link is supported. When `root` is a link (e.g., `/var/www/current` → `/var/www/releases/42`) and the link is repointed, Trickster notices at the next `revalidation_interval`, switches to the new directory, and drops every held file. Requests in flight during the switch complete against the previous release. + +## Directory Listings + +With `directory_listing: true`, a request for a directory that has no `default_file` is answered with an HTML page linking to the directory's files and subdirectories, with their sizes and modification times. Dotfiles, links that can't be followed, and anything that is not a regular file or directory are left out. A directory that does have a `default_file` is always answered with that file. Listings are generated per request, and are sent with `Cache-Control: no-cache`. diff --git a/docs/supported-backend-providers.md b/docs/supported-backend-providers.md index 8f6c8be2a..422b9276c 100644 --- a/docs/supported-backend-providers.md +++ b/docs/supported-backend-providers.md @@ -6,6 +6,10 @@ Trickster currently supports the following Providers: Trickster operates as a fully-featured and highly-customizable reverse proxy cache, designed to accelerate and scale upstream endpoints like API services and other simple http services. Specify `'reverseproxycache'` or just `'rpc'` as the Provider when configuring Trickster. +### Static File Server + +Trickster can serve the contents of a local directory itself, with no upstream origin, for hosting websites and other static content. Specify `'static'` as the Provider when configuring Trickster. See the [Static File Server Document](./static.md) for more information. + --- ## Time Series Databases diff --git a/examples/conf/example.full.yaml b/examples/conf/example.full.yaml index ad7de576a..724af5307 100644 --- a/examples/conf/example.full.yaml +++ b/examples/conf/example.full.yaml @@ -364,6 +364,60 @@ # Configuration options for mapping Origin(s) backends: + # # example static file server backend, which serves a local directory rather + # # than proxying to an origin. paths, req_rewriter_name, origin_url and other + # # proxying options are not valid for this provider. + # website: + # provider: static + # hosts: [ www.example.com ] + # # authenticator_name optionally requires users to authenticate before any file is served + # # authenticator_name: example_auth_1 + # # the static block, and its root, are required when the provider is static + # static: + # # root is the directory holding the content to serve. it must exist + # root: /var/www/html + # # default_file is served when a directory is requested. default is index.html + # default_file: index.html + # # cache_control is the Cache-Control header sent with every file. by default none is + # # sent, and clients judge a file's freshness from its Last-Modified time + # cache_control: no-cache + # # cache_control_by_extension overrides cache_control for matching files + # cache_control_by_extension: + # .js: public, max-age=31536000, immutable + # .css: public, max-age=31536000, immutable + # # response_headers are attached to every response + # response_headers: + # X-Content-Type-Options: nosniff + # # mime_types adds to and overrides the built-in Content-Types by file extension + # mime_types: + # .md: text/markdown; charset=utf-8 + # # not_found_file is a file within the root served in place of a plain 404 response. + # # not_found_status is the status it is served with: 404 (the default) for an error + # # page, or 200 for a single-page application whose routes exist only in the browser + # not_found_file: errors/404.html + # not_found_status: 404 + # # directory_listing lists a directory that has no default_file, rather than + # # answering it with a 404. default is false + # directory_listing: false + # # cache configures the fileserver cache, which holds small files in memory and drops + # # them when they change on disk. it is separate from, and unrelated to, the caches + # # configured in the caches section, and a static backend does not use a cache_name + # cache: + # # disabled serves every request from disk. default is false + # disabled: false + # # max_file_size_bytes is the largest file held in memory. default is 1048576 (1 MiB) + # max_file_size_bytes: 1048576 + # # max_size_bytes is the most memory the cache will use, counting each held file + # # as its size plus 1 KiB of bookkeeping. default is 134217728 (128 MiB) + # max_size_bytes: 134217728 + # # max_files is the most objects held (a file, and each compressed rendition of it, is + # # one), which also bounds the directories watched. the least recently used make room + # # for new ones once this or max_size_bytes is reached. default is 10000 + # max_files: 10000 + # # revalidation_interval is how often held files are compared to disk, as a + # # backstop to filesystem change events. default is 10s + # revalidation_interval: 10s + # # example mysql backend, exposed by a listener with protocol mysql (see the # # listeners section). MySQL client connections to the listener are proxied # # to the origin. Exactly one mysql backend may map to a mysql listener. @@ -662,11 +716,15 @@ backends: # # The default is false. # multipart_ranges_disabled: false -# # compressable_types defines the Content Types that will be compressed when stored in the Trickster cache -# # reasonable defaults are set, so use this with care. To disable compression, set compressable_types: [] +# # compressible_types defines the Content Types that will be compressed when stored in the Trickster cache +# # or sent to a client. reasonable defaults are set, so use this with care. To disable compression, set +# # compressible_types: [] +# # The encoding used is the one the client's Accept-Encoding header weights highest (q) of those +# # Trickster supports (zstd, br, gzip, deflate). Among equal weights, or when the client gives none, +# # Trickster prefers them in that order. A wildcard (*) stands for the encodings the client didn't +# # name. An encoding the client refuses (q=0), or weights below identity, is never used. # # Default list is provided here: -# compressable_types: -# - text/javascript, text/css, text/plain, text/xml, text/json, application/json, application/javascript, application/xml ] +# compressible_types: [ text/javascript, text/css, text/plain, text/xml, text/json, application/json, application/javascript, application/xml ] # # timeout defines how long Trickster will wait before aborting an upstream http request. Default: 60s # timeout: 60s diff --git a/integration/static_test.go b/integration/static_test.go new file mode 100644 index 000000000..2e8225860 --- /dev/null +++ b/integration/static_test.go @@ -0,0 +1,469 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package integration + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/trickstercache/trickster/v2/integration/internal/portutil" + tkconfig "github.com/trickstercache/trickster/v2/pkg/config" + po "github.com/trickstercache/trickster/v2/pkg/proxy/paths/options" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const staticTestConfig = "testdata/configs/static.yaml" + +// staticBigCSS is repetitive enough to be worth encoding, and within the 4096 bytes +// that the test config lets the site backend hold +var staticBigCSS = strings.Repeat("body { color: red; margin: 0; padding: 0 }\n", 60) + +func writeStaticFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + tmp := path + ".tmp" + require.NoError(t, os.WriteFile(tmp, []byte(content), 0o644)) + require.NoError(t, os.Rename(tmp, path)) +} + +// staticHarness writes the two sites the config serves and points it at them +func staticHarness(t *testing.T, mods ...func(*tkconfig.Config)) (h tricksterHarness, site, private string) { + t.Helper() + base, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + site, private = filepath.Join(base, "site"), filepath.Join(base, "private") + writeStaticFile(t, filepath.Join(site, "index.html"), "

home

") + writeStaticFile(t, filepath.Join(site, "app.css"), "body{}") + writeStaticFile(t, filepath.Join(site, "page.custom"), "custom") + writeStaticFile(t, filepath.Join(site, "big.bin"), strings.Repeat("x", 8192)) + writeStaticFile(t, filepath.Join(site, "docs", "index.html"), "

docs

") + writeStaticFile(t, filepath.Join(site, "empty", "a.txt"), "a") + writeStaticFile(t, filepath.Join(site, ".env"), "SECRET=1") + writeStaticFile(t, filepath.Join(site, ".git", "config"), "secret") + writeStaticFile(t, filepath.Join(site, "big.css"), staticBigCSS) + writeStaticFile(t, filepath.Join(site, ".well-known", "security.txt"), "Contact: mailto:security@example.com") + writeStaticFile(t, filepath.Join(site, ".well-known", ".secret"), "secret") + writeStaticFile(t, filepath.Join(private, "errors", "404.html"), "

nothing here

") + writeStaticFile(t, filepath.Join(private, "home.html"), "

private

") + writeStaticFile(t, filepath.Join(private, "reports", "q1.txt"), "q1") + writeStaticFile(t, filepath.Join(private, "reports", ".draft"), "draft") + + roots := func(c *tkconfig.Config) { + c.Backends["site"].Static.Root = site + c.Backends["private"].Static.Root = private + c.Backends["app"].Static.Root = site + } + ports, release := portutil.Reserve(t, 3) + h = tricksterHarness{ + ConfigPath: writeTestConfig(t, staticTestConfig, ports[0], ports[1], ports[2], 0, 0, 0, 0, + append([]func(*tkconfig.Config){roots}, mods...)...), + BaseAddr: fmt.Sprintf("127.0.0.1:%d", ports[0]), + MetricsAddr: fmt.Sprintf("127.0.0.1:%d", ports[1]), + MgmtAddr: fmt.Sprintf("127.0.0.1:%d", ports[2]), + releasePorts: release, + } + htpwPath := filepath.Join(base, "htpasswd") + writeHtpasswd(t, htpwPath, "test", "password") + rewriteGeneratedConfig(t, h.ConfigPath, "testdata/configs/htpasswd", htpwPath) + return h, site, private +} + +// staticMetric reads one series. The daemon runs in this process, so a series outlives the +// test that first touched it, and tests compare readings rather than expect absolute values. +func staticMetric(t *testing.T, h tricksterHarness, series string) float64 { + t.Helper() + for _, line := range checkTricksterMetrics(t, h.MetricsAddr) { + if value, ok := strings.CutPrefix(line, series+" "); ok { + f, err := strconv.ParseFloat(value, 64) + require.NoError(t, err) + return f + } + } + return 0 +} + +func TestStatic_ServesFiles(t *testing.T) { + h, _, _ := staticHarness(t) + h.start(t) + + tests := []struct { + path string + status int + body string + contentType string + }{ + {"/", http.StatusOK, "

home

", "text/html; charset=utf-8"}, + {"/index.html", http.StatusOK, "

home

", "text/html; charset=utf-8"}, + {"/docs/", http.StatusOK, "

docs

", "text/html; charset=utf-8"}, + {"/app.css", http.StatusOK, "body{}", "text/css; charset=utf-8"}, + {"/page.custom", http.StatusOK, "custom", "text/x-custom"}, + {"/site/docs/", http.StatusOK, "

docs

", "text/html; charset=utf-8"}, + // directory listing is off, so a directory with no default file is absent + {"/empty/", http.StatusNotFound, "", ""}, + {"/nope.html", http.StatusNotFound, "", ""}, + {"/.env", http.StatusNotFound, "", ""}, + {"/.git/config", http.StatusNotFound, "", ""}, + {"/site/.env", http.StatusNotFound, "", ""}, + } + // twice, so each file is answered from disk and then from memory + for range 2 { + for _, test := range tests { + resp, body := h.do(t, test.path) + require.Equal(t, test.status, resp.StatusCode, test.path) + require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"), test.path) + if test.status != http.StatusOK { + continue + } + require.Equal(t, test.body, string(body), test.path) + require.Equal(t, test.contentType, resp.Header.Get("Content-Type"), test.path) + require.NotEmpty(t, resp.Header.Get("Etag"), test.path) + require.NotEmpty(t, resp.Header.Get("Last-Modified"), test.path) + } + } + + resp, _ := h.do(t, "/") + require.Equal(t, "public, max-age=60", resp.Header.Get("Cache-Control")) + resp, _ = h.do(t, "/app.css") + require.Equal(t, "public, max-age=31536000, immutable", resp.Header.Get("Cache-Control")) + + resp, _ = h.do(t, "/", func(o *requestOptions) { o.method = http.MethodPost }) + require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) + require.Equal(t, "GET, HEAD, OPTIONS", resp.Header.Get("Allow")) + resp, body := h.do(t, "/", func(o *requestOptions) { o.method = http.MethodHead }) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Empty(t, body) +} + +func TestStatic_RedirectsDirectories(t *testing.T) { + h, _, _ := staticHarness(t) + h.start(t) + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + for path, location := range map[string]string{ + "/docs": "docs/", + "/docs?a=b": "docs/?a=b", + "/site/docs": "docs/", + "/private/nope/": "", + } { + req, err := http.NewRequest(http.MethodGet, "http://"+h.BaseAddr+path, nil) + require.NoError(t, err) + req.SetBasicAuth("test", "password") + resp, err := client.Do(req) + require.NoError(t, err) + resp.Body.Close() + if location == "" { + require.Equal(t, http.StatusNotFound, resp.StatusCode, path) + continue + } + require.Equal(t, http.StatusMovedPermanently, resp.StatusCode, path) + require.Equal(t, location, resp.Header.Get("Location"), path) + } + // followed, the relative redirect lands on the default file under either route + for _, path := range []string{"/docs", "/site/docs"} { + resp, body := h.do(t, path) + require.Equal(t, http.StatusOK, resp.StatusCode, path) + require.Equal(t, "

docs

", string(body), path) + } +} + +func TestStatic_ConditionalRangeAndEncoding(t *testing.T) { + h, _, _ := staticHarness(t) + h.start(t) + for _, path := range []string{"/index.html", "/big.bin"} { + // asked for first, so the validator comes from a file that was never read + head, _ := h.do(t, path, func(o *requestOptions) { o.method = http.MethodHead }) + resp, full := h.do(t, path) + require.Equal(t, head.Header.Get("Etag"), resp.Header.Get("Etag"), path) + etag, modified := resp.Header.Get("Etag"), resp.Header.Get("Last-Modified") + require.True(t, strings.HasPrefix(etag, `"`), "expected a strong etag, got %s", etag) + + resp, body := h.do(t, path, withHeader("If-None-Match", etag)) + require.Equal(t, http.StatusNotModified, resp.StatusCode, path) + require.Empty(t, body) + resp, _ = h.do(t, path, withHeader("If-Modified-Since", modified)) + require.Equal(t, http.StatusNotModified, resp.StatusCode, path) + + resp, body = h.do(t, path, withHeader("Range", "bytes=2-5")) + require.Equal(t, http.StatusPartialContent, resp.StatusCode, path) + require.Equal(t, string(full[2:6]), string(body), path) + require.Equal(t, etag, resp.Header.Get("Etag"), path) + } + + const ( + missSeries = `trickster_fileserver_responses_total{backend_name="site",cache_status="kmiss",encoding="gzip"}` + hitSeries = `trickster_fileserver_responses_total{backend_name="site",cache_status="hit",encoding="gzip"}` + usageSeries = `trickster_fileserver_cache_usage_objects{backend_name="site"}` + ) + misses, hits := staticMetric(t, h, missSeries), staticMetric(t, h, hitSeries) + // a rendition is streamed as it is made, so without a length; held, it is sent with its own + for i := range 2 { + resp, body := h.do(t, "/big.css", withHeader("Accept-Encoding", "gzip")) + require.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) + require.Equal(t, staticBigCSS, string(body), "expected a body that was encoded exactly once") + require.True(t, strings.HasPrefix(resp.Header.Get("Etag"), `W/"`), "expected a weak etag when encoded") + require.Equal(t, "Accept-Encoding", resp.Header.Get("Vary")) + if i == 0 { + // the rendition is held away from the request, which a moment's grace allows for + require.Eventually(t, func() bool { return staticMetric(t, h, usageSeries) >= 2 }, + 5*time.Second, 20*time.Millisecond, "the rendition was never held") + continue + } + require.Positive(t, resp.ContentLength) + require.Less(t, resp.ContentLength, int64(len(staticBigCSS))) + } + require.Equal(t, misses+1, staticMetric(t, h, missSeries), "expected the rendition to be made once") + require.Equal(t, hits+1, staticMetric(t, h, hitSeries), "expected the rendition to be reused") + // weights are honored: gzip is held and outweighs the rest, then is refused outright + resp, body := h.do(t, "/big.css", withHeader("Accept-Encoding", "zstd;q=0.5, GZIP;q=0.9, br;q=0.1")) + require.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) + require.Equal(t, staticBigCSS, string(body)) + resp, _ = h.do(t, "/big.css", withHeader("Accept-Encoding", "gzip;q=0, deflate;q=0.2, br;q=0.7")) + require.Equal(t, "br", resp.Header.Get("Content-Encoding"), "expected a new rendition in the highest weight") + // byte ranges address the stored file, so a partial response is never encoded + resp, body = h.do(t, "/big.css", withHeader("Accept-Encoding", "gzip"), withHeader("Range", "bytes=0-3")) + require.Equal(t, http.StatusPartialContent, resp.StatusCode) + require.Empty(t, resp.Header.Get("Content-Encoding")) + require.Equal(t, staticBigCSS[:4], string(body)) + // a file too small for encoding to shrink is sent as it is + resp, body = h.do(t, "/index.html", withHeader("Accept-Encoding", "gzip")) + require.Empty(t, resp.Header.Get("Content-Encoding")) + require.Equal(t, "

home

", string(body)) + + require.Equal(t, float64(100), staticMetric(t, h, `trickster_fileserver_cache_max_usage_objects{backend_name="site"}`)) + require.Positive(t, staticMetric(t, h, `trickster_fileserver_cache_usage_objects{backend_name="site"}`)) + require.Positive(t, staticMetric(t, h, `trickster_fileserver_cache_usage_bytes{backend_name="site"}`)) +} + +func TestStatic_WellKnown(t *testing.T) { + h, _, _ := staticHarness(t) + h.start(t) + for _, path := range []string{"/.well-known/security.txt", "/site/.well-known/security.txt"} { + resp, body := h.do(t, path) + require.Equal(t, http.StatusOK, resp.StatusCode, path) + require.Equal(t, "Contact: mailto:security@example.com", string(body), path) + } + for _, path := range []string{"/.well-known/.secret", "/.well-known/", "/.env", "/docs/.well-known/security.txt"} { + resp, _ := h.do(t, path) + require.Equal(t, http.StatusNotFound, resp.StatusCode, path) + } +} + +func TestStatic_NotFoundFile(t *testing.T) { + h, _, _ := staticHarness(t) + h.start(t) + // a single-page application: its routes are the application, as the file that it is + home, _ := h.do(t, "/app/") + for _, path := range []string{"/app/dashboard", "/app/users/42/edit", "/app/.env"} { + resp, body := h.do(t, path) + require.Equal(t, http.StatusOK, resp.StatusCode, path) + require.Equal(t, "

home

", string(body), path) + require.Equal(t, home.Header.Get("Etag"), resp.Header.Get("Etag"), path) + } + resp, body := h.do(t, "/app/app.css") + require.Equal(t, "body{}", string(body), "expected an existing file in place of the application") + require.Equal(t, http.StatusOK, resp.StatusCode) + + // an error page: a 404 that can't be mistaken for, or reused as, the missing file + auth := func(o *requestOptions) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.SetBasicAuth("test", "password") + withHeader("Authorization", r.Header.Get("Authorization"))(o) + } + resp, body = h.do(t, "/private/nope.html", auth, withHeader("Range", "bytes=0-3")) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.Equal(t, "

nothing here

", string(body)) + require.Equal(t, "no-cache", resp.Header.Get("Cache-Control")) + require.Empty(t, resp.Header.Get("Etag")) + // and only for those allowed to know what is missing + resp, body = h.do(t, "/private/nope.html") + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.NotContains(t, string(body), "nothing here") + // a backend without one answers plainly + resp, body = h.do(t, "/nope.html") + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.NotContains(t, string(body), "

") +} + +func TestStatic_EvictionKeepsServing(t *testing.T) { + h, site, _ := staticHarness(t) + for i := range 12 { + writeStaticFile(t, filepath.Join(site, "pages", fmt.Sprintf("%d.txt", i)), fmt.Sprintf("page %d", i)) + } + h.start(t) + const evictionSeries = `trickster_fileserver_cache_events_total{backend_name="app",event="eviction"}` + evictions := staticMetric(t, h, evictionSeries) + // the app backend holds 3 files, so serving 12 of them turns its cache over repeatedly + for range 2 { + for i := range 12 { + resp, body := h.do(t, fmt.Sprintf("/app/pages/%d.txt", i)) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, fmt.Sprintf("page %d", i), string(body)) + } + } + // 24 loads into room for 3, so nearly all of them made room by evicting. It is not exactly + // all: a load that finds the cache busy with the one before it is served without being held. + require.Eventually(t, func() bool { + return staticMetric(t, h, `trickster_fileserver_cache_usage_objects{backend_name="app"}`) == 3 + }, 5*time.Second, 20*time.Millisecond, "expected the cache to be full, and no fuller") + require.GreaterOrEqual(t, staticMetric(t, h, evictionSeries)-evictions, float64(12)) + require.LessOrEqual(t, staticMetric(t, h, evictionSeries)-evictions, float64(21)) +} + +func TestStatic_Authenticator(t *testing.T) { + h, _, _ := staticHarness(t) + h.start(t) + auth := func(user, pass string) requestOption { + return func(o *requestOptions) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.SetBasicAuth(user, pass) + withHeader("Authorization", r.Header.Get("Authorization"))(o) + } + } + for _, path := range []string{"/private/", "/private/reports/q1.txt", "/private/nope", "/private/.draft"} { + resp, _ := h.do(t, path) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "expected 401 with no credentials: "+path) + resp, _ = h.do(t, path, auth("test", "wrong")) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "expected 401 with bad credentials: "+path) + } + resp, body := h.do(t, "/private/", auth("test", "password")) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "

private

", string(body), "expected the configured default file") + resp, body = h.do(t, "/private/reports/q1.txt", auth("test", "password")) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "q1", string(body)) + + // this backend lists directories that have no default file, minus dotfiles + resp, body = h.do(t, "/private/reports/", auth("test", "password")) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Contains(t, string(body), `q1.txt`) + require.NotContains(t, string(body), ".draft") + resp, _ = h.do(t, "/private/reports/.draft", auth("test", "password")) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + // the open backend is unaffected by its neighbor's authenticator + resp, _ = h.do(t, "/site/") + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func requireStaticBody(t *testing.T, h tricksterHarness, path string, status int, want string) { + t.Helper() + require.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, body := h.do(t, path) + assert.Equal(collect, status, resp.StatusCode) + if status == http.StatusOK { + assert.Equal(collect, want, string(body)) + } + }, 10*time.Second, 50*time.Millisecond, "%s never served %d %q", path, status, want) +} + +func TestStatic_ChangesOnDiskAreServed(t *testing.T) { + h, site, _ := staticHarness(t) + h.start(t) + for range 2 { + requireStaticBody(t, h, "/app.css", http.StatusOK, "body{}") + } + writeStaticFile(t, filepath.Join(site, "app.css"), "body{color:red}") + requireStaticBody(t, h, "/app.css", http.StatusOK, "body{color:red}") + + require.NoError(t, os.WriteFile(filepath.Join(site, "index.html"), []byte("rewritten in place"), 0o644)) + requireStaticBody(t, h, "/", http.StatusOK, "rewritten in place") + + require.NoError(t, os.Remove(filepath.Join(site, "app.css"))) + requireStaticBody(t, h, "/app.css", http.StatusNotFound, "") + + writeStaticFile(t, filepath.Join(site, "empty", "index.html"), "no longer empty") + requireStaticBody(t, h, "/empty/", http.StatusOK, "no longer empty") +} + +func TestStatic_SurvivesReload(t *testing.T) { + // Drop prior SIGHUP handlers so this test owns the only live receiver. + signal.Reset(syscall.SIGHUP) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + h, site, _ := staticHarness(t) + if h.releasePorts != nil { + h.releasePorts() + } + runTrickster(t, ctx, "-config", h.ConfigPath) + waitForTrickster(t, h.MetricsAddr) + requireStaticBody(t, h, "/", http.StatusOK, "

home

") + + rewriteGeneratedConfig(t, h.ConfigPath, "public, max-age=60", "public, max-age=90") + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGHUP), "failed to send SIGHUP for in-process reload") + require.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, _ := h.do(t, "/") + assert.Equal(collect, "public, max-age=90", resp.Header.Get("Cache-Control")) + }, 15*time.Second, 100*time.Millisecond, "the reloaded cache_control was never served") + + // the reloaded backend holds files in memory and still notices changes + for range 2 { + requireStaticBody(t, h, "/", http.StatusOK, "

home

") + } + writeStaticFile(t, filepath.Join(site, "index.html"), "after reload") + requireStaticBody(t, h, "/", http.StatusOK, "after reload") +} + +func TestStatic_InvalidConfigs(t *testing.T) { + tests := []struct { + name, contains string + mod func(*tkconfig.Config) + }{ + {"paths", `option "paths" is not supported`, func(c *tkconfig.Config) { + c.Backends["site"].Paths = po.List{{Path: "/", HandlerName: "static"}} + }}, + {"request rewriter", `option "req_rewriter_name" is not supported`, func(c *tkconfig.Config) { + c.Backends["site"].ReqRewriterName = "rewriter1" + }}, + {"origin url", `option "origin_url" is not supported`, func(c *tkconfig.Config) { + c.Backends["site"].OriginURL = "http://127.0.0.1:9090" + }}, + {"missing static options", "missing static options", func(c *tkconfig.Config) { + c.Backends["site"].Static = nil + }}, + {"missing root", "static.root is required", func(c *tkconfig.Config) { + c.Backends["site"].Static.Root = "" + }}, + {"root does not exist", "invalid static.root", func(c *tkconfig.Config) { + c.Backends["site"].Static.Root = filepath.Join(t.TempDir(), "missing") + }}, + {"static options on a proxy", `option "static" is not supported`, func(c *tkconfig.Config) { + c.Backends["private"].Provider = "reverseproxy" + c.Backends["private"].OriginURL = "http://127.0.0.1:9090" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h, _, _ := staticHarness(t, test.mod) + if h.releasePorts != nil { + h.releasePorts() + } + startTrickster(t, context.Background(), expectedStartError{ErrorContains: &test.contains}, + "-config", h.ConfigPath) + }) + } +} diff --git a/integration/testdata/configs/static.yaml b/integration/testdata/configs/static.yaml new file mode 100644 index 000000000..9813adfac --- /dev/null +++ b/integration/testdata/configs/static.yaml @@ -0,0 +1,58 @@ +# Copyright 2018 The Trickster Authors +# Licensed under the Apache License, Version 2.0 +# +# Config for the static file server backend tests, which replace each static.root + +listeners: + default: + port: 8540 + metrics: + port: 8541 + mgmt: + port: 8542 + +logging: + log_level: info + +authenticators: + htpw1: + provider: basic + users_file: testdata/configs/htpasswd + users_file_format: htpasswd + config: {} + +backends: + site: + provider: static + is_default: true + static: + root: testdata/static/site + cache_control: public, max-age=60 + cache_control_by_extension: + .css: public, max-age=31536000, immutable + mime_types: + .custom: text/x-custom + response_headers: + X-Content-Type-Options: nosniff + cache: + max_file_size_bytes: 4096 + max_files: 100 + revalidation_interval: 250ms + private: + provider: static + authenticator_name: htpw1 + static: + root: testdata/static/private + default_file: home.html + directory_listing: true + # an error page, served with a 404 + not_found_file: errors/404.html + app: + provider: static + static: + root: testdata/static/site + # a single-page application: every unknown path is the application, with a 200 + not_found_file: index.html + not_found_status: 200 + cache: + max_files: 3 diff --git a/pkg/backends/alb/pool/target.go b/pkg/backends/alb/pool/target.go index 5eb1570bb..322d53dec 100644 --- a/pkg/backends/alb/pool/target.go +++ b/pkg/backends/alb/pool/target.go @@ -85,8 +85,8 @@ func NewWeightedTarget(handler http.Handler, hcStatus *healthcheck.Status, if backend != nil { t.name, t.group = backendIdentity(backend) if cfg := backend.Configuration(); cfg != nil && - !backends.IsVirtual(cfg.Provider) { - // non-virtual members are probed only when an active health + backends.HasOrigin(cfg.Provider) { + // members with an origin are probed only when an active health // check interval is configured; unprobed members can never // leave Unchecked and factor into healthy-floor resets t.probed = cfg.HealthCheck != nil && cfg.HealthCheck.Interval > 0 diff --git a/pkg/backends/backends.go b/pkg/backends/backends.go index 7344628ed..11acd5abe 100644 --- a/pkg/backends/backends.go +++ b/pkg/backends/backends.go @@ -55,10 +55,10 @@ func (b Backends) StartHealthChecks(knownStatuses healthcheck.StatusLookup) (hea if k == "frontend" { continue } - if IsVirtual(bo.Provider) { - // Virtual backends have no upstream to probe; register a synthetic - // passing status so they surface in the health page and in outer - // ALB pool reporting. + if !HasOrigin(bo.Provider) { + // Backends with no upstream to probe get a synthetic passing + // status so they surface in the health page and in outer ALB + // pool reporting. hc.RegisterVirtual(k, bo.Provider) continue } @@ -126,6 +126,12 @@ func IsVirtual(provider string) bool { return provider == providers.ALB || provider == providers.Rule } +// HasOrigin returns false if the backend never makes an outbound request of +// its own: a virtual type, or one that answers from local content +func HasOrigin(provider string) bool { + return !IsVirtual(provider) && provider != providers.Static +} + // CloseIdleConnections closes idle keep-alive conns on each backend's web and // health-check transports. Reload replaces the backend map without closing the // old map's transports, leaking persistConn readLoop/writeLoop goroutines until @@ -151,8 +157,8 @@ func closeIdle(c *http.Client) { } // UsesCache returns true if the backend uses a cache -// (anything except Virtuals and ReverseProxy) +// (anything except Virtuals, Static and ReverseProxy) func UsesCache(provider string) bool { - return !IsVirtual(provider) && provider != providers.ReverseProxyShort && + return HasOrigin(provider) && provider != providers.ReverseProxyShort && provider != providers.ReverseProxy } diff --git a/pkg/backends/backends_test.go b/pkg/backends/backends_test.go index d4b22b0cc..067c201cb 100644 --- a/pkg/backends/backends_test.go +++ b/pkg/backends/backends_test.go @@ -154,4 +154,27 @@ func TestUsesCache(t *testing.T) { if b { t.Error("expected false") } + if UsesCache(providers.Static) { + t.Error("expected false") + } + if !UsesCache(providers.Prometheus) { + t.Error("expected true") + } +} + +func TestHasOrigin(t *testing.T) { + for _, provider := range []string{providers.ALB, providers.Rule, providers.Static} { + if HasOrigin(provider) { + t.Errorf("expected %s to have no origin", provider) + } + } + for _, provider := range []string{providers.Prometheus, providers.ReverseProxyCache} { + if !HasOrigin(provider) { + t.Errorf("expected %s to have an origin", provider) + } + } + // static answers locally, but does not front other backends + if IsVirtual(providers.Static) { + t.Error("expected static not to be virtual") + } } diff --git a/pkg/backends/backends_virtual_test.go b/pkg/backends/backends_virtual_test.go index 116f3745d..3ebf694b1 100644 --- a/pkg/backends/backends_virtual_test.go +++ b/pkg/backends/backends_virtual_test.go @@ -37,6 +37,7 @@ func TestStartHealthChecksRegistersVirtualBackends(t *testing.T) { }{ {"alb-inner", providers.ALB}, {"rule-inner", providers.Rule}, + {"static-inner", providers.Static}, } for _, c := range cases { t.Run(c.provider, func(t *testing.T) { diff --git a/pkg/backends/options/errors.go b/pkg/backends/options/errors.go index 0ae59d34e..33d6d5eae 100644 --- a/pkg/backends/options/errors.go +++ b/pkg/backends/options/errors.go @@ -46,6 +46,31 @@ func NewErrMissingProvider(backendName string) error { } } +// ErrMissingStaticOptions is an error type for a static backend with no static block +type ErrMissingStaticOptions struct { + error +} + +// NewErrMissingStaticOptions returns a new missing static options error +func NewErrMissingStaticOptions(backendName string) error { + return &ErrMissingStaticOptions{ + error: fmt.Errorf(`missing static options for backend "%s"`, backendName), + } +} + +// ErrUnsupportedOption is an error type for an option the backend's provider can't honor +type ErrUnsupportedOption struct { + error +} + +// NewErrUnsupportedOption returns a new unsupported option error +func NewErrUnsupportedOption(option, provider, backendName string) error { + return &ErrUnsupportedOption{ + error: fmt.Errorf(`option "%s" is not supported by provider "%s" for backend "%s"`, + option, provider, backendName), + } +} + // ErrMissingOriginURL is an error type for missing origin URL type ErrMissingOriginURL struct { error diff --git a/pkg/backends/options/options.go b/pkg/backends/options/options.go index a29b2df9d..27ef2b9be 100644 --- a/pkg/backends/options/options.go +++ b/pkg/backends/options/options.go @@ -36,6 +36,7 @@ import ( prop "github.com/trickstercache/trickster/v2/pkg/backends/prometheus/options" "github.com/trickstercache/trickster/v2/pkg/backends/providers" ro "github.com/trickstercache/trickster/v2/pkg/backends/rule/options" + so "github.com/trickstercache/trickster/v2/pkg/backends/static/options" "github.com/trickstercache/trickster/v2/pkg/backends/tree" "github.com/trickstercache/trickster/v2/pkg/cache/evictionmethods" "github.com/trickstercache/trickster/v2/pkg/cache/negative" @@ -197,6 +198,8 @@ type Options struct { Graphite *gro.Options `yaml:"graphite,omitempty"` // InfluxDB holds options specific to influxdb backends InfluxDB *ino.Options `yaml:"influxdb,omitempty"` + // Static holds options specific to static file server backends, which require it + Static *so.Options `yaml:"static,omitempty"` // TLS is the TLS Configuration for the Frontend and Backend TLS *to.Options `yaml:"tls,omitempty"` @@ -395,6 +398,10 @@ func (o *Options) Clone() *Options { out.MySQL = o.MySQL.Clone() } + if o.Static != nil { + out.Static = o.Static.Clone() + } + if o.AuthOptions != nil { out.AuthOptions = o.AuthOptions.Clone() } @@ -509,6 +516,9 @@ func (o *Options) Validate() (bool, error) { return false, fmt.Errorf("backend %s: %w", o.Name, err) } } + if err := o.validateStatic(); err != nil { + return false, err + } if o.CORS != nil { if _, err := o.CORS.Validate(); err != nil { return false, err @@ -529,6 +539,49 @@ func (o *Options) Validate() (bool, error) { return true, nil } +// validateStatic requires the static block on a static backend and rejects it +// elsewhere, along with the proxying options a static backend can't honor. +func (o *Options) validateStatic() error { + if o.Provider != providers.Static { + if o.Static != nil { + return NewErrUnsupportedOption("static", o.Provider, o.Name) + } + return nil + } + if o.Static == nil { + return NewErrMissingStaticOptions(o.Name) + } + unsupported := []struct { + name string + set bool + }{ + {"paths", len(o.Paths) > 0}, + {"req_rewriter_name", o.ReqRewriterName != ""}, + {"origin_url", o.OriginURL != ""}, + {"rule_name", o.RuleName != ""}, + {"alb", o.ALBOptions != nil}, + {"prometheus", o.Prometheus != nil}, + {"mysql", o.MySQL != nil}, + {"graphite", o.Graphite != nil}, + {"influxdb", o.InfluxDB != nil}, + {"sigv4", o.SigV4 != nil}, + {"protocol", o.Protocol != ""}, + {"h2c_prior_knowledge", o.H2CPriorKnowledge}, + {"preserve_host", o.PreserveHost}, + {"proxy_only", o.ProxyOnly}, + {"is_template", o.IsTemplate}, + } + for _, u := range unsupported { + if u.set { + return NewErrUnsupportedOption(u.name, o.Provider, o.Name) + } + } + if err := o.Static.Validate(); err != nil { + return fmt.Errorf("backend %s: %w", o.Name, err) + } + return nil +} + // Validate validates the Lookup collection of Backend Options func (l Lookup) Validate() error { backendTree := make(tree.Entries, len(l)) @@ -883,6 +936,9 @@ func (o *Options) Initialize(name string) error { } } } + if err := o.Static.Initialize(); err != nil { + return err + } if o.HealthCheck != nil { if err := o.HealthCheck.Initialize(""); err != nil { diff --git a/pkg/backends/options/options_static_test.go b/pkg/backends/options/options_static_test.go new file mode 100644 index 000000000..fef0c52d8 --- /dev/null +++ b/pkg/backends/options/options_static_test.go @@ -0,0 +1,166 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package options + +import ( + "errors" + "path/filepath" + "testing" + + taws "github.com/trickstercache/trickster/v2/pkg/aws" + ao "github.com/trickstercache/trickster/v2/pkg/backends/alb/options" + gro "github.com/trickstercache/trickster/v2/pkg/backends/graphite/options" + ino "github.com/trickstercache/trickster/v2/pkg/backends/influxdb/options" + mo "github.com/trickstercache/trickster/v2/pkg/backends/mysql/options" + prop "github.com/trickstercache/trickster/v2/pkg/backends/prometheus/options" + "github.com/trickstercache/trickster/v2/pkg/backends/providers" + so "github.com/trickstercache/trickster/v2/pkg/backends/static/options" + "github.com/trickstercache/trickster/v2/pkg/cache/negative" + tro "github.com/trickstercache/trickster/v2/pkg/observability/tracing/options" + autho "github.com/trickstercache/trickster/v2/pkg/proxy/authenticator/options" + po "github.com/trickstercache/trickster/v2/pkg/proxy/paths/options" + + "go.yaml.in/yaml/v3" +) + +func newStaticTestOptions(t *testing.T) *Options { + t.Helper() + o := New() + o.Name = "site" + o.Provider = providers.Static + o.Static = so.New() + o.Static.Root = t.TempDir() + return o +} + +func TestValidateStatic(t *testing.T) { + o := newStaticTestOptions(t) + // an authenticator and virtual hosting are the supported companions + o.AuthenticatorName = "auth1" + o.AuthOptions = &autho.Options{} + o.Hosts = []string{"www.example.com"} + if _, err := o.Validate(); err != nil { + t.Errorf("expected a valid static backend, got %v", err) + } + + o = newStaticTestOptions(t) + o.Static = nil + var missing *ErrMissingStaticOptions + if _, err := o.Validate(); !errors.As(err, &missing) { + t.Errorf("expected ErrMissingStaticOptions, got %v", err) + } + + o = newStaticTestOptions(t) + o.Static.Root = "" + if _, err := o.Validate(); !errors.Is(err, so.ErrMissingRoot) { + t.Errorf("expected ErrMissingRoot, got %v", err) + } + + o = newStaticTestOptions(t) + o.Provider = providers.ReverseProxyCache + o.OriginURL = "http://example.com" + var unsupported *ErrUnsupportedOption + if _, err := o.Validate(); !errors.As(err, &unsupported) { + t.Errorf("expected ErrUnsupportedOption for a static block on another provider, got %v", err) + } +} + +func TestValidateStaticUnsupportedOptions(t *testing.T) { + tests := []struct { + name string + mod func(*Options) + }{ + {"paths", func(o *Options) { o.Paths = po.List{{Path: "/"}} }}, + {"req_rewriter_name", func(o *Options) { o.ReqRewriterName = "rewriter1" }}, + {"origin_url", func(o *Options) { o.OriginURL = "http://example.com" }}, + {"rule_name", func(o *Options) { o.RuleName = "rule1" }}, + {"alb", func(o *Options) { o.ALBOptions = &ao.Options{} }}, + {"prometheus", func(o *Options) { o.Prometheus = &prop.Options{} }}, + {"mysql", func(o *Options) { o.MySQL = mo.New() }}, + {"graphite", func(o *Options) { o.Graphite = &gro.Options{} }}, + {"influxdb", func(o *Options) { o.InfluxDB = &ino.Options{} }}, + {"sigv4", func(o *Options) { o.SigV4 = &taws.Options{} }}, + {"protocol", func(o *Options) { o.Protocol = "native" }}, + {"h2c_prior_knowledge", func(o *Options) { o.H2CPriorKnowledge = true }}, + {"preserve_host", func(o *Options) { o.PreserveHost = true }}, + {"proxy_only", func(o *Options) { o.ProxyOnly = true }}, + {"is_template", func(o *Options) { o.IsTemplate = true }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + o := newStaticTestOptions(t) + test.mod(o) + if _, err := o.Validate(); err == nil { + t.Errorf("expected %s to be rejected on a static backend", test.name) + } + }) + } +} + +func TestStaticCloneAndInitialize(t *testing.T) { + o := newStaticTestOptions(t) + o.Static.Root = "relative/site" + o.Static.MIMETypes = map[string]string{"MD": "text/markdown"} + if err := o.Initialize("site"); err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(o.Static.Root) || o.Static.MIMETypes[".md"] == "" { + t.Errorf("expected initialized static options, got %+v", o.Static) + } + c := o.Clone() + c.Static.MIMETypes[".md"] = "text/plain" + c.Static.Root = "/elsewhere" + if o.Static.MIMETypes[".md"] != "text/markdown" || o.Static.Root == c.Static.Root { + t.Error("expected cloned static options to be independent") + } +} + +func TestStaticFromYAML(t *testing.T) { + root := t.TempDir() + var l Lookup + err := yaml.Unmarshal([]byte(` +site: + provider: static + authenticator_name: auth1 + static: + root: `+root+` + default_file: home.html +`), &l) + if err != nil { + t.Fatal(err) + } + if err = l.Initialize(); err != nil { + t.Fatal(err) + } + if err = l.Validate(); err != nil { + t.Fatal(err) + } + o := l["site"] + if o.Static == nil || o.Static.Root != root || o.Static.DefaultFile != "home.html" || + o.Static.CacheControl != "" { + t.Errorf("unexpected static options %+v", o.Static) + } + // static needs no cache, so none is assigned or required + err = l.ValidateConfigMappings(nil, negative.Lookups{"default": negative.Lookup{}}, nil, nil, + autho.Lookup{"auth1": &autho.Options{}}, tro.Lookup{"default": &tro.Options{}}) + if err != nil { + t.Errorf("expected valid config mappings, got %v", err) + } + if o.AuthOptions == nil { + t.Error("expected the authenticator to be attached") + } +} diff --git a/pkg/backends/providers/providers.go b/pkg/backends/providers/providers.go index 49c303c71..f2d97f4a0 100644 --- a/pkg/backends/providers/providers.go +++ b/pkg/backends/providers/providers.go @@ -47,6 +47,8 @@ const ( GraphiteID // Druid represents the Apache Druid backend provider DruidID + // Static represents the Static File Server backend provider + StaticID Backends = "backends" @@ -56,8 +58,9 @@ const ( ReverseProxyCache = "reverseproxycache" Proxy = "proxy" - Rule = "rule" - ALB = "alb" + Rule = "rule" + ALB = "alb" + Static = "static" Prometheus = "prometheus" ClickHouse = "clickhouse" @@ -82,6 +85,7 @@ var Names = map[string]Provider{ Proxy: RPID, ReverseProxy: RPID, ReverseProxyShort: RPID, + Static: StaticID, } // Values is a map of Providers valued by string name @@ -168,12 +172,12 @@ func IsValidProvider(t string) bool { func NonCacheBackends() sets.Set[string] { return sets.New([]string{ ReverseProxyShort, - ReverseProxy, ALB, Proxy, Rule, + ReverseProxy, ALB, Proxy, Rule, Static, }) } // NonOriginBackends returns a set of backend Providers that never proxy to an -// Origin URL, but instead pass requests off to other Providers that do. +// Origin URL; they pass requests to other Providers or answer them locally. func NonOriginBackends() sets.Set[string] { - return sets.New([]string{ALB, Rule}) + return sets.New([]string{ALB, Rule, Static}) } diff --git a/pkg/backends/providers/providers_test.go b/pkg/backends/providers/providers_test.go index 1b93da599..2bad40552 100644 --- a/pkg/backends/providers/providers_test.go +++ b/pkg/backends/providers/providers_test.go @@ -96,6 +96,18 @@ func TestIsSupportedTimeSeriesProvider(t *testing.T) { } } +func TestStaticProvider(t *testing.T) { + if !IsValidProvider(Static) || StaticID.String() != Static { + t.Errorf("expected %s to be a valid provider", Static) + } + if !NonCacheBackends().Contains(Static) || !NonOriginBackends().Contains(Static) { + t.Error("expected static to need neither a cache nor an origin") + } + if IsSupportedTimeSeriesProvider(Static) { + t.Error("expected false") + } +} + func TestMySQLUsesCache(t *testing.T) { if NonCacheBackends().Contains(MySQL) { t.Fatal("MySQL must be initialized and validated with a cache") diff --git a/pkg/backends/providers/registry/registry.go b/pkg/backends/providers/registry/registry.go index 0c4587d1e..029bb26a1 100644 --- a/pkg/backends/providers/registry/registry.go +++ b/pkg/backends/providers/registry/registry.go @@ -29,6 +29,7 @@ import ( "github.com/trickstercache/trickster/v2/pkg/backends/reverseproxy" "github.com/trickstercache/trickster/v2/pkg/backends/reverseproxycache" "github.com/trickstercache/trickster/v2/pkg/backends/rule" + "github.com/trickstercache/trickster/v2/pkg/backends/static" "github.com/trickstercache/trickster/v2/pkg/proxy/listener/native" ) @@ -42,6 +43,7 @@ func SupportedProviders() types.Lookup { providers.MySQL: mysql.NewClient, providers.Prometheus: prometheus.NewClient, providers.Rule: rule.NewClient, + providers.Static: static.NewClient, providers.Proxy: reverseproxy.NewClient, providers.ReverseProxyShort: reverseproxy.NewClient, providers.ReverseProxy: reverseproxy.NewClient, diff --git a/pkg/backends/static/cache.go b/pkg/backends/static/cache.go new file mode 100644 index 000000000..5dc22d4c2 --- /dev/null +++ b/pkg/backends/static/cache.go @@ -0,0 +1,640 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/observability/metrics" + + "github.com/prometheus/client_golang/prometheus" +) + +// entryOverheadBytes is a deliberately high estimate of the memory an entry +// uses beyond its body and key: its metadata, index slots and share of a watch. +const entryOverheadBytes = 1024 + +type fileMeta struct { + info os.FileInfo + modTime time.Time + size int64 + etag string + weakETag string + contentType string + cacheControl string + compressible bool +} + +// an entry's content is immutable once stored, so readers share it without locking +type entry struct { + fileMeta + body []byte + // key is the file's path, which a rendition's own cache key extends + key string + encoding providers.Provider + cost int64 + // used marks an entry read since eviction last passed it, which spares it once + used atomic.Bool + // unencodable is a bitmap of the encodings found not to shrink the file, which aren't + // tried again. One that fails says nothing of the others, which are still worth trying. + unencodable atomic.Uint32 + // node, prev and next place the entry in the index and eviction ring, and chosen marks it + // while eviction decides whether to go ahead; they require mtx + node *dirNode + prev, next *entry + chosen bool +} + +// unhelpful returns the encodings found not to shrink the file +func (e *entry) unhelpful() providers.Provider { + return providers.Provider(e.unencodable.Load()) // #nosec G115 -- only provider bits are ever stored +} + +// renditions are the encodings a file may be held in, besides as it is stored +var renditions = []providers.Provider{ + providers.Zstandard, providers.Brotli, providers.GZip, providers.Deflate, +} + +// renditionKey is the key a file's rendition is indexed under. The separator can't occur +// in a request path, so it never collides with the key of a file that is named alike. +func renditionKey(key string, enc providers.Provider) string { + if enc == providers.Identity { + return key + } + return key + "\x00" + enc.String() +} + +// cache events, as reported in metrics +const ( + eventEviction = "eviction" + eventInvalidation = "invalidation" +) + +// cacheMetrics holds a cache's series, resolved once to keep label lookups off the request path +type cacheMetrics struct { + evictions, invalidations prometheus.Counter + // objects and bytes are nil unless this cache is the one that publishes them + objects, bytes prometheus.Gauge +} + +// newCacheMetrics returns counters that are private until the cache goes into service +func newCacheMetrics() *cacheMetrics { + return &cacheMetrics{evictions: unpublished(), invalidations: unpublished()} +} + +// gaugeOwners maps a backend's name to the one cache that publishes its gauges. A reload builds +// a cache of the same name while the last is still draining, and only one can speak for the name. +var gaugeOwners sync.Map + +// dirWatcher is the part of a filesystem watcher the cache drives +type dirWatcher interface { + Watch(dir string) + Unwatch(dir string) +} + +// dirNode is one directory in the cache's index of what it holds, which lets a change +// reach exactly the files it affects (one file, or one subtree) and nothing else. +type dirNode struct { + parent *dirNode + name string + children map[string]*dirNode + // held and pending are the keys of the directory's own entries and loads in progress + held map[string]*entry + pending map[string]*reservation + // osDir is the directory's path for the watcher, watched while it has files + osDir string + watched bool +} + +func (n *dirNode) files() int { + return len(n.held) + len(n.pending) +} + +// fileCache holds small files in memory. Reads are lock-free; a file that changes on +// disk is dropped rather than updated in place, and the least recently used make room. +type fileCache struct { + // entries holds a map per rendition, each of key (root-relative slash path) -> *entry, + // so that probing for a file's renditions reuses its key rather than building others + entries [maxRendition + 1]sync.Map + // size and files include reservations for loads still in progress + size atomic.Int64 + files atomic.Int64 + // active is false until a watcher is running, as unwatched entries would go stale + active atomic.Bool + maxFileSize int64 + maxSize int64 + maxFiles int64 + + // mtx guards the index and every change to entries. Never taken on the read path. + mtx sync.Mutex + root *dirNode + watcher dirWatcher + // hand is where eviction resumes in the ring of held entries, which is ringLen long + hand *entry + ringLen int + name string + metrics *cacheMetrics + // invalidated counts the entries dropped since the count was last published + invalidated int +} + +func newFileCache(name string, maxFileSize, maxSize, maxFiles int64, w dirWatcher) *fileCache { + return &fileCache{ + maxFileSize: maxFileSize, maxSize: maxSize, maxFiles: maxFiles, name: name, + root: &dirNode{}, watcher: w, metrics: newCacheMetrics(), + } +} + +// activate begins holding files, and takes over the publishing of the backend's gauges. It is +// only called for a cache that is going into service, so one built to be validated publishes nothing. +func (c *fileCache) activate() { + c.mtx.Lock() + defer c.mtx.Unlock() + gaugeOwners.Store(c.name, c) + c.metrics.evictions = metrics.FileserverCacheEvents.WithLabelValues(c.name, eventEviction) + c.metrics.invalidations = metrics.FileserverCacheEvents.WithLabelValues(c.name, eventInvalidation) + metrics.FileserverCacheMaxObjects.WithLabelValues(c.name).Set(float64(c.maxFiles)) + metrics.FileserverCacheMaxBytes.WithLabelValues(c.name).Set(float64(c.maxSize)) + c.metrics.objects = metrics.FileserverCacheObjects.WithLabelValues(c.name) + c.metrics.bytes = metrics.FileserverCacheBytes.WithLabelValues(c.name) + c.active.Store(true) + c.report() +} + +// retire stops holding files and gives up the gauges, so that nothing still draining from this +// cache can publish over its replacement. The series go with it unless a replacement has them. +func (c *fileCache) retire() { + c.mtx.Lock() + defer c.mtx.Unlock() + c.active.Store(false) + c.metrics.objects, c.metrics.bytes = nil, nil + if gaugeOwners.CompareAndDelete(c.name, c) { + metrics.FileserverCacheObjects.DeleteLabelValues(c.name) + metrics.FileserverCacheBytes.DeleteLabelValues(c.name) + metrics.FileserverCacheMaxObjects.DeleteLabelValues(c.name) + metrics.FileserverCacheMaxBytes.DeleteLabelValues(c.name) + } +} + +func (c *fileCache) get(key string, enc providers.Provider) *entry { + if c == nil || !c.active.Load() { + return nil + } + if v, ok := c.entries[enc].Load(key); ok { + e := v.(*entry) + // marking is the only write a hit makes, and it is skipped once marked + if !e.used.Load() { + e.used.Store(true) + } + return e + } + return nil +} + +func entryCost(key string, size int64) int64 { + return size + int64(len(key)) + entryOverheadBytes +} + +// admits cheaply reports whether a file of the given size may be held. A full +// cache still admits, as reserve evicts to make room. +func (c *fileCache) admits(key string, size int64) bool { + return c != nil && c.active.Load() && size <= c.maxFileSize && entryCost(key, size) <= c.maxSize +} + +// link and unlink require mtx. The ring is circular, and hand is nil only when it is empty. +func (c *fileCache) link(e *entry) { + c.ringLen++ + if c.hand == nil { + e.prev, e.next, c.hand = e, e, e + return + } + // placed just behind the hand, so it is the last entry eviction will reach + e.next, e.prev = c.hand, c.hand.prev + e.prev.next, e.next.prev = e, e +} + +func (c *fileCache) unlink(e *entry) { + c.ringLen-- + if e.next == e { + c.hand = nil + } else { + e.prev.next, e.next.prev = e.next, e.prev + if c.hand == e { + c.hand = e.next + } + } + e.prev, e.next = nil, nil +} + +// evictionBudget is the most entries a request will pass over without evicting them. Without +// it, a cache full of recently used entries would have a request look at every one of them. +// The entries it does evict need no budget, as they are in proportion to the file they admit. +const evictionBudget = 128 + +// maxEvictions is the most entries one file will be admitted at the cost of, which with +// evictionBudget bounds everything a request does to the cache before it is served. An entry's +// watch is let go of by the watcher in its own time, so an eviction makes no call to the platform. +const maxEvictions = 128 + +// makeRoom requires mtx. It evicts the least recently used entries to fit cost, and only if +// that is enough to: it chooses what would go first, and takes nothing if room can't be made, so +// that a file that isn't admitted costs the cache nothing it holds. pin is never chosen. +func (c *fileCache) makeRoom(cost int64, pin *entry) bool { + files, size := c.files.Load()+1-c.maxFiles, c.size.Load()+cost-c.maxSize + if files <= 0 && size <= 0 { + return true + } + var chosen []*entry + budget := evictionBudget + e := c.hand + // two laps at most: one in which an entry used since the hand last passed is spared and + // its mark cleared, and one in which, unread since, it is chosen + for steps := 2 * c.ringLen; e != nil && steps > 0 && budget >= 0 && (files > 0 || size > 0); steps-- { + switch { + case e.chosen: + case e == pin || e.used.Swap(false): + budget-- + case len(chosen) == maxEvictions: + // more would have to go than one file is worth; nothing is taken, as below + steps = 0 + default: + e.chosen = true + chosen = append(chosen, e) + files-- + size -= e.cost + } + e = e.next + } + if files > 0 || size > 0 { + // the marks that were cleared stay cleared, so requests that keep coming for room age + // the cache toward giving it; but nothing is taken from it for a file it didn't admit + for _, v := range chosen { + v.chosen = false + } + return false + } + c.hand = e + for _, v := range chosen { + c.remove(v) + } + // published once for the pass, rather than for every entry, as the lock is held throughout + c.metrics.evictions.Add(float64(len(chosen))) + c.report() + return true +} + +// remove requires mtx. It drops a held entry and lets its directory settle. +func (c *fileCache) remove(e *entry) { + n := e.node + c.discard(e) + c.settle(n) +} + +// discard requires mtx. It drops a held entry without settling its directory or publishing +// the change, which its caller does once for everything it drops. +func (c *fileCache) discard(e *entry) { + delete(e.node.held, renditionKey(e.key, e.encoding)) + c.entries[e.encoding].Delete(e.key) + c.unlink(e) + c.size.Add(-e.cost) + c.files.Add(-1) +} + +// report requires mtx. It publishes usage, if this cache is the one that speaks for its name. +func (c *fileCache) report() { + if c.metrics.objects == nil { + return + } + c.metrics.objects.Set(float64(c.files.Load())) + c.metrics.bytes.Set(float64(c.size.Load())) +} + +// published requires mtx. It publishes what an invalidation dropped, once for all of it. +func (c *fileCache) published() { + if c.invalidated > 0 { + c.metrics.invalidations.Add(float64(c.invalidated)) + c.invalidated = 0 + c.report() + } +} + +// splitKey separates a key into the path of its directory and its own name +func splitKey(key string) (dir, name string) { + if before, after, ok := strings.CutLast(key, "/"); ok { + return before, after + } + return "", key +} + +// node returns the index node for a directory path, optionally creating it. +// It requires mtx, and costs the depth of the path rather than the size of the cache. +func (c *fileCache) node(dir string, create bool) *dirNode { + n := c.root + for dir != "" { + var name string + name, dir, _ = strings.Cut(dir, "/") + child := n.children[name] + if child == nil { + if !create { + return nil + } + child = &dirNode{parent: n, name: name} + if n.children == nil { + n.children = make(map[string]*dirNode) + } + n.children[name] = child + } + n = child + } + return n +} + +// settle requires mtx. It stops watching a directory that has no files left, +// and unlinks it and any ancestors that the index no longer needs. +func (c *fileCache) settle(n *dirNode) { + for n != nil { + if n.files() == 0 && n.watched { + c.watcher.Unwatch(n.osDir) + n.watched = false + } + if n.parent == nil || n.files() > 0 || len(n.children) > 0 { + return + } + delete(n.parent.children, n.name) + n = n.parent + } +} + +// a reservation holds capacity and a directory watch for a file that is about +// to be read, so memory is bounded before the read allocates anything. +type reservation struct { + c *fileCache + node *dirNode + // key is the index key of the rendition being loaded + key string + cost int64 + // basis, for a rendition, is the entry it is derived from, which must still be held + basis *entry + // invalid is set when the file changed after the reservation was made; done + // once it is committed or cancelled. Both require the cache's mtx. + invalid bool + done bool + // scheduled is set by the one holder of a shared reservation that goes on to store it + scheduled atomic.Bool +} + +// claim reports whether the caller is the one to schedule the reservation's store. Loads that +// shared one read share its reservation, and only the first of them to ask is told to. +func (r *reservation) claim() bool { + return r.scheduled.CompareAndSwap(false, true) +} + +// reserve returns nil when key can't be held, or already is or is being loaded; otherwise the +// caller must commit or cancel. The directory is watched first, so a later change invalidates it. +func (c *fileCache) reserve(path string, enc providers.Provider, osDir string, size int64, + basis *entry, +) *reservation { + if c == nil || size > c.maxFileSize { + return nil + } + key := renditionKey(path, enc) + cost := entryCost(key, size) + if cost > c.maxSize { + return nil + } + // reserve is the one use of the lock on a request's path, so it is never waited for: + // a request that finds it busy is served without being held, which costs only a later load + if !c.mtx.TryLock() { + return nil + } + defer c.mtx.Unlock() + if !c.active.Load() || !c.basisUsable(basis) { + return nil + } + dir, _ := splitKey(key) + // looked up before creating, so that a refusal leaves no empty nodes behind + if n := c.node(dir, false); n != nil { + if n.held[key] != nil || n.pending[key] != nil { + return nil + } + } + // evicted for before the node is found, as eviction may unlink emptied nodes. A rendition's + // basis is held out of it: evicting the file to hold a rendition that needs it holds neither. + if !c.makeRoom(cost, basis) { + return nil + } + n := c.node(dir, true) + r := &reservation{c: c, node: n, key: key, cost: cost, basis: basis} + if n.pending == nil { + n.pending = make(map[string]*reservation) + } + n.pending[key] = r + c.size.Add(cost) + c.files.Add(1) + c.report() + if !n.watched { + n.osDir, n.watched = osDir, true + c.watcher.Watch(osDir) + } + return r +} + +// basisHeld requires mtx. A rendition derived from an entry that has since been +// dropped may be of content that has since changed, so it can't be held. +func (c *fileCache) basisHeld(basis *entry) bool { + if basis == nil { + return true + } + v, ok := c.entries[providers.Identity].Load(basis.key) + return ok && v.(*entry) == basis +} + +// basisUsable requires mtx. A rendition may be begun from an entry that is held or is +// still on its way to being; only once it is held can the rendition be held too. +func (c *fileCache) basisUsable(basis *entry) bool { + if c.basisHeld(basis) { + return true + } + dir, _ := splitKey(basis.key) + if n := c.node(dir, false); n != nil { + r := n.pending[basis.key] + return r != nil && !r.invalid + } + return false +} + +// finish requires mtx. It ends the reservation, reporting whether it was still open. +func (r *reservation) finish() bool { + if r.done { + return false + } + r.done = true + delete(r.node.pending, r.key) + return true +} + +func (r *reservation) cancel() { + c := r.c + c.mtx.Lock() + defer c.mtx.Unlock() + if r.finish() { + c.release(r.node, r.cost) + } +} + +// commit stores e, handing it the capacity it needs of what was reserved. It fails, releasing +// the reservation, if the file was invalidated, its basis dropped, or the cache stopped. +func (r *reservation) commit(e *entry) bool { + c := r.c + c.mtx.Lock() + defer c.mtx.Unlock() + if !r.finish() { + return false + } + if r.invalid || !c.active.Load() || !c.basisHeld(r.basis) { + c.release(r.node, r.cost) + return false + } + e.cost = min(r.cost, entryCost(r.key, int64(len(e.body)))) + c.size.Add(e.cost - r.cost) + e.node = r.node + if r.node.held == nil { + r.node.held = make(map[string]*entry) + } + r.node.held[r.key] = e + c.link(e) + c.entries[e.encoding].Store(e.key, e) + c.report() + return true +} + +// release requires mtx. It returns capacity and lets the directory settle. +func (c *fileCache) release(n *dirNode, cost int64) { + c.size.Add(-cost) + c.files.Add(-1) + c.report() + c.settle(n) +} + +// drop requires mtx. It removes what is held or being loaded under one cache key +// in n, without settling n, so that a caller walking the index can do so afterward. +func (c *fileCache) drop(n *dirNode, key string) { + // a load in progress keeps its capacity until its loader finishes, as its body + // may already be allocated; it is only barred from being stored + if r := n.pending[key]; r != nil { + r.invalid = true + } + if e := n.held[key]; e != nil { + c.discard(e) + c.invalidated++ + } +} + +// dropFile requires mtx. It drops a file along with every rendition of it. +func (c *fileCache) dropFile(n *dirNode, key string) { + c.drop(n, key) + for _, enc := range renditions { + c.drop(n, renditionKey(key, enc)) + } +} + +// dropTree requires mtx. It visits only n's subtree. +func (c *fileCache) dropTree(n *dirNode) { + for _, child := range n.children { + c.dropTree(child) + } + for key := range n.held { + c.drop(n, key) + } + for key := range n.pending { + c.drop(n, key) + } + c.settle(n) +} + +// invalidate drops the one file at key, in every rendition +func (c *fileCache) invalidate(key string) { + if c == nil { + return + } + c.mtx.Lock() + defer c.mtx.Unlock() + c.invalidateFile(key) + c.published() +} + +func (c *fileCache) invalidateFile(key string) { + dir, _ := splitKey(key) + if n := c.node(dir, false); n != nil { + c.dropFile(n, key) + c.settle(n) + } +} + +// invalidatePath applies a change to the path at key, which may name a file, or +// a directory that takes everything beneath it along. Nothing else is visited. +func (c *fileCache) invalidatePath(key string) { + if c == nil { + return + } + c.mtx.Lock() + defer c.mtx.Unlock() + c.invalidateFile(key) + if n := c.node(key, false); n != nil { + c.dropTree(n) + } + c.published() +} + +func (c *fileCache) purge() { + if c == nil { + return + } + c.mtx.Lock() + defer c.mtx.Unlock() + c.dropTree(c.root) + c.published() +} + +// revalidate drops every file for which stale returns true. stale may block on the filesystem, +// so it is called without mtx, and once per file however many renditions of it are held. +func (c *fileCache) revalidate(stale func(key string, e *entry) bool) { + if c == nil { + return + } + for i := range c.entries { + c.entries[i].Range(func(_, v any) bool { + e := v.(*entry) + // a file is checked through the first of its entries: as stored where that is held, + // which it may not be, as the stored file is evicted apart from its renditions + for earlier := range i { + if _, ok := c.entries[earlier].Load(e.key); ok { + return true + } + } + if stale(e.key, e) { + c.invalidate(e.key) + } + return true + }) + } +} diff --git a/pkg/backends/static/cache_test.go b/pkg/backends/static/cache_test.go new file mode 100644 index 000000000..5e64f857e --- /dev/null +++ b/pkg/backends/static/cache_test.go @@ -0,0 +1,802 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "os" + "path/filepath" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/observability/metrics" + "github.com/trickstercache/trickster/v2/pkg/watchers/filesystem" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +type fakeWatcher struct { + mtx sync.Mutex + dirs map[string]bool +} + +func newFakeWatcher() *fakeWatcher { + return &fakeWatcher{dirs: make(map[string]bool)} +} + +func (w *fakeWatcher) Watch(dir string) { + w.mtx.Lock() + defer w.mtx.Unlock() + w.dirs[dir] = true +} + +func (w *fakeWatcher) Unwatch(dir string) { + w.mtx.Lock() + defer w.mtx.Unlock() + delete(w.dirs, dir) +} + +func (w *fakeWatcher) watched() int { + w.mtx.Lock() + defer w.mtx.Unlock() + return len(w.dirs) +} + +func (c *fileCache) count() int { + var n int + if c != nil { + for i := range c.entries { + c.entries[i].Range(func(_, _ any) bool { n++; return true }) + } + } + return n +} + +// nodes counts the index's directories below its root +func (c *fileCache) nodes() int { + c.mtx.Lock() + defer c.mtx.Unlock() + var walk func(n *dirNode) int + walk = func(n *dirNode) int { + total := len(n.children) + for _, child := range n.children { + total += walk(child) + } + return total + } + return walk(c.root) +} + +// ring counts the entries eviction can reach, which must be exactly those held +func (c *fileCache) ring() int { + c.mtx.Lock() + defer c.mtx.Unlock() + if c.hand == nil { + return 0 + } + n := 1 + for e := c.hand.next; e != c.hand; e = e.next { + n++ + } + return n +} + +var testNames atomic.Int64 + +// testName is unique to each run of a test, as a backend's metrics are series that are +// shared by everything of its name and would otherwise carry over between runs +func testName(t testing.TB) string { + return t.Name() + "#" + strconv.FormatInt(testNames.Add(1), 10) +} + +func newTestCache(t *testing.T, maxFileSize, maxSize, maxFiles int64) (*fileCache, *fakeWatcher) { + w := newFakeWatcher() + c := newFileCache(testName(t), maxFileSize, maxSize, maxFiles, w) + c.activate() + t.Cleanup(c.retire) + return c, w +} + +func testEntry(key, body string) *entry { + return &entry{key: key, body: []byte(body)} +} + +// hold reserves and commits a body under key in dir, as a completed load does +func (c *fileCache) hold(key, dir, body string) bool { + rsv := c.reserve(key, providers.Identity, dir, int64(len(body)), nil) + if rsv == nil { + return false + } + return rsv.commit(testEntry(key, body)) +} + +func (c *fileCache) held(key string) *entry { + return c.get(key, providers.Identity) +} + +func TestFileCacheNilIsInert(t *testing.T) { + var c *fileCache + if c.held("a") != nil || c.admits("a", 1) || c.count() != 0 || + c.reserve("a", providers.Identity, "/d", 1, nil) != nil { + t.Error("expected a nil cache to hold nothing") + } + c.invalidate("a") + c.invalidatePath("a") + c.purge() + c.revalidate(func(string, *entry) bool { return true }) +} + +func TestFileCacheInactive(t *testing.T) { + c := newFileCache(testName(t), 10, 10000, 10, newFakeWatcher()) + if c.admits("a", 1) || c.hold("a", "/d", "a") || c.held("a") != nil { + t.Error("expected an inactive cache to hold nothing") + } +} + +func TestFileCacheAccounting(t *testing.T) { + c, _ := newTestCache(t, 10, 2*entryCost("a", 8), 10) + if !c.admits("a", 10) || c.admits("a", 11) || c.reserve("a", providers.Identity, "/d", 11, nil) != nil { + t.Error("expected admission to follow the per-file limit") + } + tiny, _ := newTestCache(t, 10, entryCost("a", 1)-1, 10) + if tiny.admits("a", 1) || tiny.hold("a", "/d", "1") { + t.Error("expected a file that could never fit not to be admitted") + } + if !c.hold("a", "/d", "12345678") { + t.Fatal("expected the entry to be stored") + } + if c.hold("a", "/d", "1") { + t.Error("expected a held key not to be replaced") + } + // overhead is charged per entry, so even an empty file takes capacity + if !c.hold("b", "/d", "") || c.size.Load() != entryCost("a", 8)+entryCost("b", 0) { + t.Errorf("expected an empty file to be charged its overhead, size is %d", c.size.Load()) + } + if c.files.Load() != 2 || c.count() != 2 || c.ring() != 2 { + t.Errorf("expected 2 files, got %d", c.files.Load()) + } + if got := testutil.ToFloat64(c.metrics.bytes); got != float64(c.size.Load()) { + t.Errorf("expected the usage metric to follow the accounting, got %v", got) + } + c.purge() + if c.size.Load() != 0 || c.files.Load() != 0 || c.ring() != 0 || testutil.ToFloat64(c.metrics.objects) != 0 { + t.Errorf("expected accounting to return to zero, got %d bytes %d files", c.size.Load(), c.files.Load()) + } +} + +func TestFileCacheEvictsLeastRecentlyUsed(t *testing.T) { + c, w := newTestCache(t, 10, 1<<30, 3) + for _, dir := range []string{"a", "b", "c"} { + c.hold(dir+"/f", "/r/"+dir, "x") + } + // every entry starts unread; a and c are read, so b is the least recently used + c.held("a/f") + c.held("c/f") + if !c.hold("d/f", "/r/d", "x") { + t.Fatal("expected a full cache to make room") + } + if c.held("b/f") != nil || c.count() != 3 || c.files.Load() != 3 || c.ring() != 3 { + t.Errorf("expected b to be evicted, %d entries remain", c.count()) + } + if w.watched() != 3 || c.nodes() != 3 { + t.Errorf("expected the evicted entry's directory to be released, %d watched", w.watched()) + } + if got := testutil.ToFloat64(c.metrics.evictions); got != 1 { + t.Errorf("expected 1 eviction to be counted, got %v", got) + } + // having all been read, each is spared once; the hand then evicts as it goes + for _, dir := range []string{"a", "c", "d"} { + c.held(dir + "/f") + } + c.hold("e/f", "/r/e", "x") + if c.count() != 3 || c.held("e/f") == nil { + t.Error("expected room to be made even when every entry was recently used") + } +} + +func TestFileCacheEvictsForBytes(t *testing.T) { + c, _ := newTestCache(t, 100, 3*entryCost("a", 50), 100) + for _, key := range []string{"a", "b", "c"} { + c.hold(key, "/d", string(make([]byte, 50))) + } + // a larger file needs more than one entry's room + big := string(make([]byte, 100)) + if !c.hold("d", "/d", big) || c.size.Load() > c.maxSize { + t.Fatalf("expected room for a larger file, size is %d of %d", c.size.Load(), c.maxSize) + } + if c.held("d") == nil || c.count() != 2 { + t.Errorf("expected 2 entries after evicting for a larger one, got %d", c.count()) + } + // loads in progress can't be evicted, so what they hold is not available + c.purge() + pending := c.reserve("p", providers.Identity, "/d", 100, nil) + pending2 := c.reserve("q", providers.Identity, "/d", 100, nil) + if pending == nil || pending2 == nil || c.hold("r", "/d", big) { + t.Error("expected capacity held by loads in progress not to be evictable") + } + pending.cancel() + if !c.hold("r", "/d", big) { + t.Error("expected released capacity to be reusable") + } + pending2.cancel() +} + +func TestFileCacheReservation(t *testing.T) { + c, w := newTestCache(t, 10, 10000, 10) + // capacity is taken when the load begins, before its body exists + rsv := c.reserve("a", providers.Identity, "/d", 8, nil) + if rsv == nil || c.size.Load() != entryCost("a", 8) || c.files.Load() != 1 || w.watched() != 1 { + t.Fatal("expected a reservation to take capacity and a watch") + } + if c.reserve("a", providers.Identity, "/d", 8, nil) != nil { + t.Error("expected a file that is already being loaded not to be reserved twice") + } + rsv.cancel() + rsv.cancel() // finishing twice must not release twice + if rsv.commit(testEntry("a", "late")) { + t.Error("expected a cancelled reservation not to commit") + } + if c.size.Load() != 0 || c.files.Load() != 0 || w.watched() != 0 || c.nodes() != 0 { + t.Error("expected a cancelled reservation to release everything") + } + // a load that began before its file changed must not land after it + rsv = c.reserve("a", providers.Identity, "/d", 5, nil) + c.invalidate("a") + if c.size.Load() == 0 { + t.Error("expected a load in progress to keep its capacity until its loader finishes") + } + if rsv.commit(testEntry("a", "stale")) || c.held("a") != nil || c.size.Load() != 0 || w.watched() != 0 { + t.Error("expected a load that raced an invalidation to be discarded") + } + if !c.hold("a", "/d", "fresh") || string(c.held("a").body) != "fresh" { + t.Error("expected a load after the invalidation to be stored") + } + if c.reserve("a", providers.Identity, "/d", 5, nil) != nil { + t.Error("expected a held file not to be reserved") + } + // capacity reserved for more than the body turned out to need is handed back + rsv = c.reserve("s", providers.Identity, "/d", 10, nil) + before := c.size.Load() + if !rsv.commit(testEntry("s", "12")) || c.size.Load() != before-8 { + t.Errorf("expected the unused reservation to be released, size went %d to %d", before, c.size.Load()) + } + // so must one that raced the cache being stopped + rsv = c.reserve("b", providers.Identity, "/d", 1, nil) + c.active.Store(false) + if rsv.commit(testEntry("b", "b")) || c.reserve("c", providers.Identity, "/d", 1, nil) != nil { + t.Error("expected a load that raced a stop to be discarded") + } +} + +func TestFileCacheRenditions(t *testing.T) { + c, _ := newTestCache(t, 100, 1<<20, 100) + c.hold("site/app.js", "/r/site", "identity body") + // a real file named like a rendition is a different key entirely + c.hold("site/app.js.gzip", "/r/site", "a file") + basis := c.held("site/app.js") + rsv := c.reserve("site/app.js", providers.GZip, "/r/site", 13, basis) + if rsv == nil || !rsv.commit(&entry{key: "site/app.js", encoding: providers.GZip, body: []byte("gz")}) { + t.Fatal("expected the rendition to be held") + } + if v := c.get("site/app.js", providers.GZip); v == nil || string(v.body) != "gz" { + t.Fatal("expected the rendition under its own key") + } + if c.get("site/app.js", providers.Brotli) != nil || string(c.held("site/app.js").body) != "identity body" { + t.Error("expected other renditions to be unaffected") + } + if c.count() != 3 || c.nodes() != 1 { + t.Errorf("expected 3 entries in one directory, got %d in %d", c.count(), c.nodes()) + } + // a rendition of an entry that is no longer the one held may be of other content + stale := c.reserve("site/app.js", providers.Brotli, "/r/site", 13, basis) + c.invalidate("site/app.js") + if c.get("site/app.js", providers.GZip) != nil || c.held("site/app.js") != nil { + t.Error("expected a changed file to take every rendition along") + } + if c.held("site/app.js.gzip") == nil { + t.Error("expected the file named like a rendition to be untouched") + } + c.hold("site/app.js", "/r/site", "new identity") + if stale.commit(&entry{key: "site/app.js", encoding: providers.Brotli, body: []byte("br")}) { + t.Error("expected a rendition of replaced content to be refused") + } + if c.reserve("site/app.js", providers.Zstandard, "/r/site", 13, basis) != nil { + t.Error("expected no reservation against an entry that is no longer held") + } + // revalidation drops a stale file through any one of its entries + fresh := c.held("site/app.js") + rsv = c.reserve("site/app.js", providers.Zstandard, "/r/site", 13, fresh) + rsv.commit(&entry{key: "site/app.js", encoding: providers.Zstandard, body: []byte("zs")}) + c.revalidate(func(key string, _ *entry) bool { return key == "site/app.js" }) + if c.count() != 1 { + t.Errorf("expected only the unrelated file to remain, got %d", c.count()) + } +} + +func TestFileCacheRevalidatesEachFileOnce(t *testing.T) { + c, _ := newTestCache(t, 100, 1<<20, 100) + rendition := func(key string, enc providers.Provider) { + rsv := c.reserve(key, enc, "/r", 5, c.held(key)) + if rsv == nil || !rsv.commit(&entry{key: key, encoding: enc, body: []byte("x")}) { + t.Fatalf("expected the %s rendition of %s to be held", enc, key) + } + } + // one file held every way it can be, one only as stored, and one only as renditions + for _, key := range []string{"all.js", "stored.js", "orphan.js"} { + c.hold(key, "/r", "stored") + } + for _, enc := range renditions { + rendition("all.js", enc) + } + rendition("orphan.js", providers.Brotli) + rendition("orphan.js", providers.Deflate) + // the stored file is evicted apart from its renditions, which must still be checked + c.mtx.Lock() + c.remove(c.held("orphan.js")) + c.mtx.Unlock() + if c.count() != 8 { + t.Fatalf("expected 8 entries, got %d", c.count()) + } + checked := make(map[string]int) + c.revalidate(func(key string, _ *entry) bool { + checked[key]++ + return false + }) + if len(checked) != 3 || checked["all.js"] != 1 || checked["stored.js"] != 1 || checked["orphan.js"] != 1 { + t.Errorf("expected each file to be checked once, however it is held, got %v", checked) + } + // and a stale one goes in every rendition, through whichever of its entries was checked + c.revalidate(func(key string, _ *entry) bool { return key != "stored.js" }) + if c.count() != 1 || c.held("stored.js") == nil { + t.Errorf("expected only the file that wasn't stale to remain, got %d", c.count()) + } +} + +func TestFileCacheDeclinedAdmissionTakesNothing(t *testing.T) { + const held = 4 * evictionBudget + c, _ := newTestCache(t, 1<<20, 1<<40, held) + for i := range held { + c.hold(strconv.Itoa(i), "/r", "x") + } + // every entry has been read, so more of them would have to be passed over than a request will + for i := range held { + c.held(strconv.Itoa(i)) + } + if c.hold("new", "/r", "x") { + t.Fatal("expected a request not to pass over more entries than its budget") + } + if c.count() != held || c.ring() != held || testutil.ToFloat64(c.metrics.evictions) != 0 { + t.Errorf("expected a declined admission to have taken nothing, %d of %d remain", c.count(), held) + } + // it aged the entries it passed, though, so a request that comes again is admitted + if !c.hold("new", "/r", "x") || c.count() != held || testutil.ToFloat64(c.metrics.evictions) != 1 { + t.Errorf("expected the next request to be admitted for one eviction, got %v evictions", + testutil.ToFloat64(c.metrics.evictions)) + } + // unless those entries were read in between, as ones in use are + c.purge() + for i := range held { + c.hold(strconv.Itoa(i), "/r", "x") + } + for range 3 { + for i := range held { + c.held(strconv.Itoa(i)) + } + if c.hold("other", "/r", "x") || c.count() != held { + t.Fatal("expected a cache in constant use to be left as it is") + } + } +} + +func TestFileCacheLargeFileTakesNothingUnlessAdmitted(t *testing.T) { + each := entryCost("1000", 8) + c, _ := newTestCache(t, 1<<30, 1000*each, 2000) + for i := range 1000 { + c.hold(strconv.Itoa(1000+i), "/r", "12345678") + } + // a larger file that many unread small ones make way for is admitted in one pass + if !c.hold("big", "/r", string(make([]byte, (maxEvictions-10)*each))) || c.size.Load() > c.maxSize { + t.Fatal("expected room to be made for a large file among many unread small ones") + } + evicted := testutil.ToFloat64(c.metrics.evictions) + if evicted < maxEvictions-10 || evicted > maxEvictions { + t.Errorf("expected about %d entries to make way, got %v", maxEvictions-10, evicted) + } + if got := testutil.ToFloat64(c.metrics.objects); got != float64(c.files.Load()) { + t.Errorf("expected usage to be published as it stands after the pass, got %v", got) + } + // one that more entries would have to make way for than a request will evict takes nothing + before := c.count() + if c.hold("vast", "/r", string(make([]byte, (maxEvictions+50)*each))) { + t.Fatal("expected a file that too many entries would make way for to be declined") + } + if c.count() != before || testutil.ToFloat64(c.metrics.evictions) != evicted { + t.Errorf("expected a declined file to have evicted nothing, %d of %d remain", c.count(), before) + } + // nor does one that can't be fitted because too much of the cache is in use + for i := range 1000 { + c.held(strconv.Itoa(1000 + i)) + } + c.held("big") + if c.hold("other", "/r", string(make([]byte, 20*each))) || c.count() != before { + t.Errorf("expected a file that there isn't room for to take nothing, %d of %d remain", c.count(), before) + } + // with the capacity taken by loads in progress, there is nothing to evict at all + c.purge() + a := c.reserve("a", providers.Identity, "/r", 600*each, nil) + if a == nil || c.reserve("b", providers.Identity, "/r", 600*each, nil) != nil { + t.Error("expected a reservation that nothing can be evicted for to be declined") + } + a.cancel() +} + +func TestFileCacheRenditionNeverEvictsItsBasis(t *testing.T) { + rendition := func(c *fileCache, key string, size int64) bool { + rsv := c.reserve(key, providers.GZip, "/r", size, c.held(key)) + return rsv != nil && rsv.commit(&entry{key: key, encoding: providers.GZip, body: make([]byte, size)}) + } + // room for one object: the file is what is kept, not a rendition that would need it + c, _ := newTestCache(t, 100, 1<<20, 1) + c.hold("app.js", "/r", "stored") + for range 3 { + if rendition(c, "app.js", 3) { + t.Fatal("expected a rendition that can't be held beside its file to be declined") + } + if c.held("app.js") == nil || c.count() != 1 || testutil.ToFloat64(c.metrics.evictions) != 0 { + t.Fatal("expected the file to be kept, rather than evicted for a rendition that needs it") + } + } + // room for two, with the file at the hand, where it would be the first to go + c, _ = newTestCache(t, 100, 1<<20, 2) + c.hold("app.js", "/r", "stored") + c.hold("other.js", "/r", "stored") + if c.hand != c.held("app.js") { + t.Fatal("expected the file to be where eviction resumes") + } + if !rendition(c, "app.js", 3) || c.held("app.js") == nil || c.held("other.js") != nil { + t.Error("expected the other entry to make way, and the file and its rendition to be held") + } + // and the same by bytes: only the file could make enough room, so the rendition is declined + c, _ = newTestCache(t, 100, entryCost("app.js", 50)+entryCost(renditionKey("app.js", providers.GZip), 20)-1, 10) + c.hold("app.js", "/r", string(make([]byte, 50))) + if rendition(c, "app.js", 20) || c.held("app.js") == nil { + t.Error("expected the file to be kept when only it could make room for its rendition") + } +} + +func TestFileCacheGaugesHaveOneOwner(t *testing.T) { + name := testName(t) + gauges := []*prometheus.GaugeVec{ + metrics.FileserverCacheObjects, metrics.FileserverCacheBytes, + metrics.FileserverCacheMaxObjects, metrics.FileserverCacheMaxBytes, + } + published := func() bool { + // deleting reports whether there was a series to delete, which is put back if so + var found bool + for _, g := range gauges { + if v := testutil.ToFloat64(g.WithLabelValues(name)); g.DeleteLabelValues(name) && v != 0 { + found = true + g.WithLabelValues(name).Set(v) + } + } + return found + } + // a cache built to validate a configuration, and then not used, publishes nothing + rejected := newFileCache(name, 10, 1<<20, 7, newFakeWatcher()) + if rejected.hold("a", "/r", "x") || published() { + t.Error("expected a cache that never went into service to publish nothing") + } + + first := newFileCache(name, 10, 1<<20, 7, newFakeWatcher()) + first.activate() + first.hold("a", "/r", "x") + draining := first.reserve("b", providers.Identity, "/r", 1, nil) + if got := testutil.ToFloat64(metrics.FileserverCacheObjects.WithLabelValues(name)); got != 2 { + t.Fatalf("expected the cache in service to publish its usage, got %v", got) + } + if got := testutil.ToFloat64(metrics.FileserverCacheMaxObjects.WithLabelValues(name)); got != 7 { + t.Fatalf("expected the cache in service to publish its limits, got %v", got) + } + + // a reload replaces it with a cache of the same name while it is still draining + first.retire() + second := newFileCache(name, 10, 1<<20, 9, newFakeWatcher()) + second.activate() + for _, key := range []string{"a", "b", "c"} { + second.hold(key, "/r", "x") + } + draining.cancel() + first.purge() + if got := testutil.ToFloat64(metrics.FileserverCacheObjects.WithLabelValues(name)); got != 3 { + t.Errorf("expected a retired cache not to publish over its replacement, got %v", got) + } + if got := testutil.ToFloat64(metrics.FileserverCacheMaxObjects.WithLabelValues(name)); got != 9 { + t.Errorf("expected the replacement's limits, got %v", got) + } + // retired again, as a rollback may do, it still leaves its replacement's series alone + first.retire() + if !published() { + t.Error("expected the replacement's series to have survived the retired cache") + } + // with no cache left for the name (it was disabled, or the backend removed), the series go + second.retire() + if published() { + t.Error("expected the series to be removed with the last cache of the name") + } + // and a cache restored by a rollback publishes again + first.activate() + defer first.retire() + if !published() { + t.Error("expected a cache put back into service to publish") + } +} + +func TestFileCacheInvalidationIsScoped(t *testing.T) { + c, _ := newTestCache(t, 10, 1<<20, 100) + c.hold("site/a.css", "/r/site", "x") + // loads in progress all over the tree when one unrelated file changes + sibling := c.reserve("site/b.css", providers.Identity, "/r/site", 1, nil) + elsewhere := c.reserve("other/deep/c.css", providers.Identity, "/r/other/deep", 1, nil) + top := c.reserve("index.html", providers.Identity, "/r", 1, nil) + c.invalidatePath("site/a.css") + c.invalidatePath("site/never-held.css") + c.invalidatePath("unknown/dir/file.css") + if c.held("site/a.css") != nil { + t.Error("expected the changed file to be dropped") + } + if got := testutil.ToFloat64(c.metrics.invalidations); got != 1 { + t.Errorf("expected 1 invalidation to be counted, got %v", got) + } + loads := map[string]*reservation{"site/b.css": sibling, "other/deep/c.css": elsewhere, "index.html": top} + for key, rsv := range loads { + if !rsv.commit(testEntry(key, "x")) { + t.Errorf("expected the load of %s to survive a change to another file", key) + } + } + + // a changed directory takes its own subtree, held and in progress, and nothing else + inTree := c.reserve("other/deep/d.css", providers.Identity, "/r/other/deep", 1, nil) + outside := c.reserve("site/e.css", providers.Identity, "/r/site", 1, nil) + lookalike := c.reserve("other2/f.css", providers.Identity, "/r/other2", 1, nil) + c.invalidatePath("other") + if c.held("other/deep/c.css") != nil || inTree.commit(testEntry("other/deep/d.css", "x")) { + t.Error("expected everything under the changed directory to be refused") + } + if c.held("site/b.css") == nil || c.held("index.html") == nil || + !outside.commit(testEntry("site/e.css", "x")) || !lookalike.commit(testEntry("other2/f.css", "x")) { + t.Error("expected everything outside the changed directory to be untouched") + } + c.purge() + if c.size.Load() != 0 || c.files.Load() != 0 || c.nodes() != 0 || c.ring() != 0 { + t.Errorf("expected nothing to remain, got %d files %d nodes", c.files.Load(), c.nodes()) + } +} + +func TestFileCacheWatchesFollowEntries(t *testing.T) { + c, w := newTestCache(t, 10, 1<<20, 100) + c.hold("a/1", "/root/a", "x") + c.hold("a/2", "/root/a", "x") + c.hold("b/1", "/root/b", "x") + if w.watched() != 2 { + t.Fatalf("expected one watch per directory, got %d", w.watched()) + } + c.invalidate("a/1") + if w.watched() != 2 { + t.Error("expected a directory to stay watched while it holds an entry") + } + c.invalidate("a/2") + if w.watched() != 1 { + t.Error("expected a directory holding nothing to be released") + } + // a directory that only leads to others is indexed, but has nothing to watch + c.hold("c/d/e/1", "/root/c/d/e", "x") + if w.watched() != 2 || c.nodes() != 4 { + t.Errorf("expected 2 watches over 4 indexed directories, got %d over %d", w.watched(), c.nodes()) + } + c.invalidate("c/d/e/1") + if c.nodes() != 1 { + t.Errorf("expected emptied directories to leave the index, %d remain", c.nodes()) + } + c.purge() + if w.watched() != 0 || c.nodes() != 0 { + t.Error("expected a purge to release every watch") + } +} + +func TestFileCacheTreeAndPurge(t *testing.T) { + c, _ := newTestCache(t, 10, 1<<20, 100) + // docs is held as a file here, and is also a directory: a changed path may be either + for _, key := range []string{"docs", "docs/a", "docs/sub/b", "docs2/c", "d"} { + c.hold(key, "/d", "x") + } + c.invalidatePath("docs") + if c.count() != 2 || c.held("docs2/c") == nil || c.held("d") == nil { + t.Errorf("expected only the named tree to be dropped, %d entries remain", c.count()) + } + c.revalidate(func(key string, _ *entry) bool { return key == "d" }) + if c.count() != 1 || c.held("d") != nil { + t.Error("expected the stale entry to be dropped") + } + c.purge() + if c.count() != 0 || c.size.Load() != 0 { + t.Error("expected a purge to drop everything") + } +} + +func TestFileCacheConcurrent(t *testing.T) { + c, w := newTestCache(t, 10, 1<<20, 3) + keys := []string{"a", "b", "c", "d", "e", "f"} + var wg sync.WaitGroup + for i := range 8 { + wg.Go(func() { + for j := range 2000 { + key := keys[(i+j)%len(keys)] + switch j % 4 { + case 0: + c.hold(key, "/d/"+key, "12345") + case 1: + c.held(key) + case 2: + if e := c.held(key); e != nil { + if rsv := c.reserve(key, providers.GZip, "/d/"+key, 5, e); rsv != nil { + rsv.commit(&entry{key: key, encoding: providers.GZip, body: []byte("gz")}) + } + } + default: + c.invalidatePath(key) + } + if n := c.files.Load(); n > 3 { + t.Errorf("expected the file limit to hold under contention, got %d", n) + return + } + } + }) + } + wg.Wait() + if c.ring() != c.count() { + t.Errorf("expected eviction to reach exactly what is held, %d of %d", c.ring(), c.count()) + } + c.purge() + if c.size.Load() != 0 || c.files.Load() != 0 || c.count() != 0 || w.watched() != 0 || c.nodes() != 0 { + t.Errorf("expected accounting to settle at zero, got %d bytes %d files %d watches %d nodes", + c.size.Load(), c.files.Load(), w.watched(), c.nodes()) + } +} + +func BenchmarkInvalidateFile(b *testing.B) { + for _, held := range []int{100, 10000} { + b.Run(strconv.Itoa(held), func(b *testing.B) { + w := newFakeWatcher() + c := newFileCache(testName(b), 10, 1<<40, int64(held)+1, w) + c.activate() + defer c.retire() + for i := range held { + dir := "assets/" + strconv.Itoa(i%100) + c.hold(dir+"/"+strconv.Itoa(i)+".css", "/r/"+dir, "x") + } + for b.Loop() { + // as a deployment does: a file event for a path that is not a directory + c.invalidatePath("assets/7/changed.css") + } + }) + } +} + +func BenchmarkGet(b *testing.B) { + w := newFakeWatcher() + c := newFileCache(testName(b), 10, 1<<40, 1000, w) + c.activate() + defer c.retire() + c.hold("assets/app.css", "/r/assets", "x") + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.get("assets/app.css", providers.Identity) + } + }) +} + +func BenchmarkReserveFullRecentlyUsed(b *testing.B) { + const held = 10000 + c := newFileCache(testName(b), 1<<20, 1<<40, held, newFakeWatcher()) + c.activate() + defer c.retire() + for i := range held { + c.hold(strconv.Itoa(i), "/r", "x") + } + var n int + for b.Loop() { + b.StopTimer() + // full again, whatever the last pass evicted, and every entry read since the hand last + // passed: the most that a reservation can have to do + for c.files.Load() < held { + n++ + c.hold("refill"+strconv.Itoa(n), "/r", "x") + } + for i := range c.entries { + c.entries[i].Range(func(_, v any) bool { v.(*entry).used.Store(true); return true }) + } + b.StartTimer() + rsv := c.reserve("new", providers.Identity, "/r", 1, nil) + b.StopTimer() + if rsv != nil { + rsv.cancel() + } + b.StartTimer() + } +} + +// benchmarkWorstAdmission measures the most a request can do to the cache before it is served: +// pass over a budget of entries in use, and then evict as many as one admission may, each the +// last file in a directory of its own, so that each takes its directory's watch with it +func benchmarkWorstAdmission(b *testing.B, w dirWatcher, dir func(i int) string) { + const held = 4 * (evictionBudget + maxEvictions) + each := entryCost("00000/f", 1) + c := newFileCache(testName(b), 1<<30, held*each, 1<<20, w) + c.activate() + defer c.retire() + key := func(i int) string { return strconv.Itoa(10000+i) + "/f" } + for b.Loop() { + b.StopTimer() + c.purge() + for i := range held { + c.hold(key(i), dir(i), "x") + } + // the hand starts at the first entry: a budget of used entries, then unused ones to evict + for i := range evictionBudget - 1 { + c.get(key(i), providers.Identity) + } + b.StartTimer() + rsv := c.reserve("big", providers.Identity, dir(held), (maxEvictions-1)*each, nil) + b.StopTimer() + if rsv == nil { + b.Fatal("expected the file to be admitted") + } + rsv.cancel() + b.StartTimer() + } +} + +func BenchmarkWorstAdmission(b *testing.B) { + benchmarkWorstAdmission(b, newFakeWatcher(), func(i int) string { return "/r/" + strconv.Itoa(i) }) +} + +// BenchmarkWorstAdmissionWatched is the same against the filesystem, with a real watcher +// armed on every directory that an evicted entry was the last file of +func BenchmarkWorstAdmissionWatched(b *testing.B) { + const held = 4 * (evictionBudget + maxEvictions) + parent := b.TempDir() + dirs := make([]string, held+1) + for i := range dirs { + dirs[i] = filepath.Join(parent, strconv.Itoa(i)) + if err := os.Mkdir(dirs[i], 0o700); err != nil { + b.Fatal(err) + } + } + w, err := filesystem.NewDirWatcher(&filesystem.DirOptions{ + Name: b.Name(), Interval: time.Hour, OnEvent: func(string) {}, + }) + if err != nil { + b.Fatal(err) + } + w.Start() + defer w.Close() + benchmarkWorstAdmission(b, w, func(i int) string { return dirs[i] }) +} diff --git a/pkg/backends/static/client.go b/pkg/backends/static/client.go new file mode 100644 index 000000000..860cfafc4 --- /dev/null +++ b/pkg/backends/static/client.go @@ -0,0 +1,116 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package static provides the Static File Server backend provider, which +// serves the content of a local directory rather than proxying to an origin. +package static + +import ( + "net/http" + + "github.com/trickstercache/trickster/v2/pkg/backends" + bo "github.com/trickstercache/trickster/v2/pkg/backends/options" + "github.com/trickstercache/trickster/v2/pkg/backends/providers" + "github.com/trickstercache/trickster/v2/pkg/backends/providers/registry/types" + "github.com/trickstercache/trickster/v2/pkg/cache" + "github.com/trickstercache/trickster/v2/pkg/proxy/handlers" + "github.com/trickstercache/trickster/v2/pkg/proxy/methods" + "github.com/trickstercache/trickster/v2/pkg/proxy/paths/matching" + po "github.com/trickstercache/trickster/v2/pkg/proxy/paths/options" +) + +// Client implements the Backend interface for the Static File Server +type Client struct { + backends.Backend + server *server + handler http.Handler +} + +var _ types.NewBackendClientFunc = NewClient + +// NewClient returns a new Static File Server client. Its fileserver cache stays +// idle until Start is called, so a client built only to validate holds nothing. +func NewClient(name string, o *bo.Options, router http.Handler, + _ cache.Cache, _ backends.Backends, _ types.Lookup, +) (backends.Backend, error) { + if o == nil || o.Static == nil { + return nil, ErrMissingOptions + } + s, err := newServer(name, o.Static, o.CompressibleTypes) + if err != nil { + return nil, err + } + c := &Client{server: s, handler: s} + if o.Static.DirectoryListing { + c.handler = withDirectoryListing(s, c.handler) + } + c.handler = withResponseHeaders(o.Static.ResponseHeaders, c.handler) + b, err := backends.New(name, o, c.RegisterHandlers, router, nil) + c.Backend = b + return c, err +} + +// Start enables the client's fileserver cache and its filesystem watcher +func (c *Client) Start() { + c.server.start() +} + +// Stop disables the client's fileserver cache and its filesystem watcher +func (c *Client) Stop() { + c.server.stop() +} + +// StartClients starts every Static File Server client in the collection. It is called with +// all of a configuration's clients, so that what the last one's left behind can be cleaned up. +func StartClients(clients backends.Backends) { + for _, c := range clients { + if sc, ok := c.(*Client); ok { + sc.Start() + } + } + sweepSeries() +} + +// StopClients stops every Static File Server client in the collection +func StopClients(clients backends.Backends) { + for _, c := range clients { + if sc, ok := c.(*Client); ok { + sc.Stop() + } + } +} + +// DefaultPathConfigs returns the default PathConfigs for the given Provider +func (c *Client) DefaultPathConfigs(_ *bo.Options) po.List { + return po.List{ + { + Path: "/", + HandlerName: providers.Static, + Methods: methods.AllHTTPMethods(), + MatchType: matching.PathMatchTypePrefix, + MatchTypeName: matching.PathMatchNamePrefix, + }, + } +} + +// RegisterHandlers registers the client's handlers with the backend +func (c *Client) RegisterHandlers(handlers.Lookup) { + c.Backend.RegisterHandlers( + handlers.Lookup{ + providers.Static: c.handler, + }, + ) +} diff --git a/pkg/backends/static/client_test.go b/pkg/backends/static/client_test.go new file mode 100644 index 000000000..111cae750 --- /dev/null +++ b/pkg/backends/static/client_test.go @@ -0,0 +1,115 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "errors" + "net/http" + "path/filepath" + "testing" + + "github.com/trickstercache/trickster/v2/pkg/backends" + bo "github.com/trickstercache/trickster/v2/pkg/backends/options" + "github.com/trickstercache/trickster/v2/pkg/backends/providers" + "github.com/trickstercache/trickster/v2/pkg/proxy/methods" +) + +func testBackendOptions(root string) *bo.Options { + o := bo.New() + o.Provider = providers.Static + o.Static = testOptions(root) + o.Static.FileserverCache.RevalidationInterval = 10 * 1000 * 1000 * 60 * 60 // 10h + return o +} + +func TestNewClient(t *testing.T) { + if _, err := NewClient("test", nil, nil, nil, nil, nil); !errors.Is(err, ErrMissingOptions) { + t.Errorf("expected ErrMissingOptions for nil options, got %v", err) + } + if _, err := NewClient("test", bo.New(), nil, nil, nil, nil); !errors.Is(err, ErrMissingOptions) { + t.Errorf("expected ErrMissingOptions for a missing static block, got %v", err) + } + o := testBackendOptions(filepath.Join(t.TempDir(), "missing")) + if _, err := NewClient("test", o, nil, nil, nil, nil); err == nil { + t.Error("expected an error for a missing root") + } + b, err := NewClient("test", testBackendOptions(newTestSite(t)), nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if b.Name() != "test" { + t.Errorf("expected client named test, got %s", b.Name()) + } +} + +func TestDefaultPathConfigs(t *testing.T) { + dpc := (&Client{}).DefaultPathConfigs(nil) + if len(dpc) != 1 { + t.Fatalf("expected 1 default path, got %d", len(dpc)) + } + if dpc[0].Path != "/" || dpc[0].HandlerName != providers.Static || + len(dpc[0].Methods) != len(methods.AllHTTPMethods()) { + t.Error("expected a catch-all path routed to the static handler") + } +} + +func TestClientHandlerComposition(t *testing.T) { + o := testBackendOptions(newTestSite(t)) + o.Static.DirectoryListing = true + o.Static.ResponseHeaders = map[string]string{"X-Test": "1"} + b, err := NewClient("test", o, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + h, ok := b.Handlers()[providers.Static] + if !ok { + t.Fatal("expected a registered static handler") + } + resp := get(t, h, http.MethodGet, "/empty/") + if resp.StatusCode != http.StatusOK || resp.Header.Get("X-Test") != "1" { + t.Errorf("expected a listing carrying the configured header, got %d", resp.StatusCode) + } +} + +func TestStartAndStopClients(t *testing.T) { + b, err := NewClient("test", testBackendOptions(newTestSite(t)), nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + c := b.(*Client) + other, err := backends.New("other", bo.New(), nil, nil, nil) + if err != nil { + t.Fatal(err) + } + clients := backends.Backends{"test": c, "other": other} + StartClients(clients) + get(t, c.handler, http.MethodGet, "/") + // the store is made away from the request + c.server.stores.Wait() + if c.server.cache.count() != 1 { + t.Error("expected a started client to hold the requested file") + } + StopClients(clients) + if c.server.cache.active.Load() || c.server.cache.count() != 0 { + t.Error("expected a stopped client to hold nothing") + } + // both are safe to repeat, as a reload may stop clients that never started + StopClients(clients) + StartClients(clients) + StartClients(clients) + StopClients(clients) +} diff --git a/pkg/backends/static/encoding.go b/pkg/backends/static/encoding.go new file mode 100644 index 000000000..378bf7288 --- /dev/null +++ b/pkg/backends/static/encoding.go @@ -0,0 +1,108 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "io" + "net/http" + + "github.com/trickstercache/trickster/v2/pkg/cache/status" + "github.com/trickstercache/trickster/v2/pkg/encoding/profile" + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/proxy/headers" +) + +// cacheStatus is how the Fileserver cache figured in a response, as reported in metrics +type cacheStatus int + +const ( + // statusHit is a response served as it was held + statusHit cacheStatus = iota + // statusPartialHit is a held file encoded for the response, and the rendition then held + statusPartialHit + // statusMiss is a file read from disk for the response, and then held + statusMiss + // statusDisk is a file sent from disk without being held + statusDisk + numCacheStatuses +) + +const ( + statusDiskLabel = "disk" + identityLabel = "identity" + // maxRendition is the highest-valued encoding, which sizes tables indexed by encoding + maxRendition = providers.Deflate +) + +var cacheStatusLabels = [numCacheStatuses]string{ + statusHit: status.StatusHit, + statusPartialHit: status.StatusPartialHit, + statusMiss: status.StatusKeyMiss, + statusDisk: statusDiskLabel, +} + +func encodingLabel(enc providers.Provider) string { + if enc == providers.Identity { + return identityLabel + } + return enc.String() +} + +// minRenditionSize is the smallest file worth encoding. Below it an encoding's own +// framing outweighs what it saves, so the file is sent, and held, only as it is stored. +const minRenditionSize = 512 + +// newEncoder returns a streaming encoder for enc. It is replaced in tests to fail. +var newEncoder = func(enc providers.Provider, w io.Writer) io.WriteCloser { + init, _ := providers.SelectEncoderInitializer(enc) + return init(w, -1) +} + +// acceptedEncodings returns what a request can be served a rendition in, most preferred +// first. That is nothing for one that isn't a GET or HEAD, or asks for a byte range, +// as ranges address the stored file. +func acceptedEncodings(r *http.Request) providers.Accepted { + if (r.Method != http.MethodGet && r.Method != http.MethodHead) || + r.Header.Get(headers.NameRange) != "" { + return providers.Accepted{} + } + // already worked out by the response path where there is one, which may also have + // narrowed what it will pass through since + if ep := profile.FromContext(r.Context()); ep != nil { + return ep.Accepted.Filter(ep.Supported) + } + return providers.ParseAcceptEncoding(r.Header[headers.NameAcceptEncoding]...) +} + +// teeBuffer collects what is written to it up to a limit, past which it gives up +// collecting rather than fail the write, as the response it shadows must carry on. +type teeBuffer struct { + buf []byte + limit int + overflow bool +} + +func (t *teeBuffer) Write(b []byte) (int, error) { + if !t.overflow { + if len(t.buf)+len(b) > t.limit { + t.overflow, t.buf = true, nil + } else { + t.buf = append(t.buf, b...) + } + } + return len(b), nil +} diff --git a/pkg/backends/static/middleware.go b/pkg/backends/static/middleware.go new file mode 100644 index 000000000..c821a1991 --- /dev/null +++ b/pkg/backends/static/middleware.go @@ -0,0 +1,163 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "bytes" + "html" + "maps" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/proxy/headers" +) + +// The handlers in this file are optional features. Each wraps the file server +// only when its option is configured, so an unused feature costs nothing. + +// withResponseHeaders attaches the configured headers to every response +func withResponseHeaders(custom map[string]string, next http.Handler) http.Handler { + if len(custom) == 0 { + return next + } + canonical := make(map[string][]string, len(custom)) + for k, v := range custom { + canonical[http.CanonicalHeaderKey(k)] = []string{v} + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + maps.Copy(h, canonical) + next.ServeHTTP(w, r) + }) +} + +// withDirectoryListing answers a directory that has no default file with a +// listing of its contents; every other request passes through to next. +func withDirectoryListing(s *server, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + next.ServeHTTP(w, r) + return + } + name, dirRequest, ok := resolve(r.URL.Path) + if !ok || !dirRequest || s.hasDefaultFile(name) || !s.list(w, r, name) { + next.ServeHTTP(w, r) + } + }) +} + +func (s *server) hasDefaultFile(dir string) bool { + key := s.defaultFileIn(dir) + if s.cache.get(key, providers.Identity) != nil { + return true + } + fi, err := s.root.Load().root.Stat(key) + return err == nil && fi.Mode().IsRegular() +} + +// list writes the listing and reports whether name was a listable directory +func (s *server) list(w http.ResponseWriter, r *http.Request, name string) bool { + f, err := s.root.Load().root.Open(name) + if err != nil { + return false + } + defer f.Close() + entries, err := f.ReadDir(-1) + if err != nil { + return false + } + type item struct { + name string + size int64 + modTime time.Time + dir bool + } + items := make([]item, 0, len(entries)) + for _, de := range entries { + if strings.HasPrefix(de.Name(), ".") { + continue + } + // stat through the root so a symlink is described by its target + fi, err := s.root.Load().root.Stat(childName(name, de.Name())) + if err != nil || (!fi.IsDir() && !fi.Mode().IsRegular()) { + continue + } + items = append(items, item{de.Name(), fi.Size(), fi.ModTime(), fi.IsDir()}) + } + slices.SortFunc(items, func(a, b item) int { + if a.dir != b.dir { + if a.dir { + return -1 + } + return 1 + } + return strings.Compare(a.name, b.name) + }) + + title := html.EscapeString("Index of " + r.URL.Path) + var b bytes.Buffer + b.WriteString("\n\n\n\n" + + "\n") + b.WriteString(title) + b.WriteString("\n\n\n

") + b.WriteString(title) + b.WriteString("

\n\n" + + "\n") + if name != rootName { + b.WriteString("\n") + } + for _, it := range items { + display, size := it.name, strconv.FormatInt(it.size, 10) + if it.dir { + display, size = display+"/", "-" + } + href := url.URL{Path: "./" + display} + b.WriteString("\n") + } + b.WriteString("
NameLast ModifiedSize
../
") + b.WriteString(html.EscapeString(display)) + b.WriteString("") + b.WriteString(it.modTime.UTC().Format(time.RFC1123)) + b.WriteString("") + b.WriteString(size) + b.WriteString("
\n\n\n") + + h := w.Header() + h.Set(headers.NameContentType, contentTypeHTML) + // a listing is generated per request and has no validator to revalidate with + h.Set(headers.NameCacheControl, headers.ValueNoCache) + h.Set(headers.NameContentLength, strconv.Itoa(b.Len())) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + w.Write(b.Bytes()) + } + return true +} + +func childName(dir, name string) string { + if dir == rootName { + return name + } + return dir + "/" + name +} diff --git a/pkg/backends/static/middleware_test.go b/pkg/backends/static/middleware_test.go new file mode 100644 index 000000000..7d5c2d92f --- /dev/null +++ b/pkg/backends/static/middleware_test.go @@ -0,0 +1,131 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/trickstercache/trickster/v2/pkg/proxy/headers" +) + +func TestWithResponseHeaders(t *testing.T) { + s := newTestServer(t, testOptions(newTestSite(t))) + if h := withResponseHeaders(nil, s); h != http.Handler(s) { + t.Error("expected no wrapper when no headers are configured") + } + h := withResponseHeaders(map[string]string{"x-frame-options": "DENY"}, s) + for _, target := range []string{"/", "/nope"} { + resp := get(t, h, http.MethodGet, target) + if resp.Header.Get("X-Frame-Options") != "DENY" { + t.Errorf("%s: expected the configured response header", target) + } + } +} + +func TestWithDirectoryListing(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "empty", "sub", "b.txt"), "b") + writeFile(t, filepath.Join(root, "empty", ".secret"), "secret") + writeFile(t, filepath.Join(root, "empty", "&.txt"), "escaped") + if err := os.Symlink(filepath.Join(filepath.Dir(root), "outside"), filepath.Join(root, "empty", "escape")); err != nil { + t.Fatal(err) + } + s := newTestServer(t, testOptions(root)) + s.start() + h := withDirectoryListing(s, s) + + resp := get(t, h, http.MethodGet, "/empty/") + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected a listing, got %d", resp.StatusCode) + } + if ct := resp.Header.Get(headers.NameContentType); ct != contentTypeHTML { + t.Errorf("expected an html listing, got %q", ct) + } + if resp.Header.Get(headers.NameCacheControl) != headers.ValueNoCache { + t.Error("expected a listing not to be reused without revalidation") + } + page := body(t, resp) + for _, want := range []string{ + "Index of /empty/", ``, `sub/`, + `a.txt`, "<b>&.txt", `href="./%3Cb%3E&.txt"`, + } { + if !strings.Contains(page, want) { + t.Errorf("expected the listing to contain %q:\n%s", want, page) + } + } + for _, unwanted := range []string{".secret", "escape", ""} { + if strings.Contains(page, unwanted) { + t.Errorf("expected the listing not to contain %q", unwanted) + } + } + if strings.Index(page, "sub/") > strings.Index(page, "a.txt") { + t.Error("expected directories to be listed before files") + } + + resp = get(t, h, http.MethodHead, "/empty/") + if resp.StatusCode != http.StatusOK || body(t, resp) != "" || + resp.Header.Get(headers.NameContentLength) == "" { + t.Error("expected a HEAD listing to carry headers and no body") + } + + // the site root has a default file, so it is served rather than listed + writeFile(t, filepath.Join(root, "bare", "only.txt"), "only") + for range 2 { + if resp = get(t, h, http.MethodGet, "/"); body(t, resp) != testHome { + t.Error("expected the default file in place of a listing") + } + } + if resp = get(t, h, http.MethodGet, "/bare/"); strings.Contains(body(t, resp), `href="../"`) == false { + t.Error("expected a parent link in a subdirectory listing") + } + + passthrough := []struct { + method, target string + status int + }{ + {http.MethodGet, "/empty", http.StatusMovedPermanently}, + {http.MethodGet, "/empty/a.txt", http.StatusOK}, + {http.MethodGet, "/empty/a.txt/", http.StatusNotFound}, + {http.MethodGet, "/nope/", http.StatusNotFound}, + {http.MethodGet, "/.git/", http.StatusNotFound}, + {http.MethodPost, "/empty/", http.StatusMethodNotAllowed}, + } + for _, test := range passthrough { + if resp = get(t, h, test.method, test.target); resp.StatusCode != test.status { + t.Errorf("%s %s: expected %d got %d", test.method, test.target, test.status, resp.StatusCode) + } + } +} + +func TestDirectoryListingOfRoot(t *testing.T) { + root := newTestSite(t) + if err := os.Remove(filepath.Join(root, "index.html")); err != nil { + t.Fatal(err) + } + s := newTestServer(t, testOptions(root)) + page := body(t, get(t, withDirectoryListing(s, s), http.MethodGet, "/")) + if !strings.Contains(page, `docs/`) || strings.Contains(page, `href="../"`) { + t.Errorf("expected a root listing with no parent link:\n%s", page) + } + if strings.Contains(page, ".env") || strings.Contains(page, ".git") { + t.Error("expected dotfiles to be left out of the listing") + } +} diff --git a/pkg/backends/static/mime.go b/pkg/backends/static/mime.go new file mode 100644 index 000000000..ae15c4e64 --- /dev/null +++ b/pkg/backends/static/mime.go @@ -0,0 +1,177 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "mime" + "net/http" + "path" + "strings" +) + +const ( + contentTypeOctetStream = "application/octet-stream" + contentTypeHTML = "text/html; charset=utf-8" + + sniffLen = 512 +) + +// builtinTypes covers the common web content types, so resolution does not +// depend on the MIME databases installed on the host or container image. +var builtinTypes = map[string]string{ + ".html": contentTypeHTML, + ".htm": contentTypeHTML, + ".xhtml": "application/xhtml+xml", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".cjs": "text/javascript; charset=utf-8", + ".json": "application/json", + ".jsonld": "application/ld+json", + ".map": "application/json", + ".webmanifest": "application/manifest+json", + ".xml": "application/xml", + ".rss": "application/rss+xml", + ".atom": "application/atom+xml", + ".txt": "text/plain; charset=utf-8", + ".text": "text/plain; charset=utf-8", + ".log": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".csv": "text/csv; charset=utf-8", + ".tsv": "text/tab-separated-values; charset=utf-8", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".toml": "application/toml", + ".ics": "text/calendar; charset=utf-8", + ".vtt": "text/vtt; charset=utf-8", + ".wasm": "application/wasm", + ".pdf": "application/pdf", + ".rtf": "application/rtf", + + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".apng": "image/apng", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".cur": "image/x-icon", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".heic": "image/heic", + ".heif": "image/heif", + ".jxl": "image/jxl", + + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".oga": "audio/ogg", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".wav": "audio/wav", + ".weba": "audio/webm", + ".flac": "audio/flac", + ".mid": "audio/midi", + ".midi": "audio/midi", + + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".m4s": "video/iso.segment", + ".webm": "video/webm", + ".ogv": "video/ogg", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".mpeg": "video/mpeg", + ".mpg": "video/mpeg", + ".ts": "video/mp2t", + ".m3u8": "application/vnd.apple.mpegurl", + ".mpd": "application/dash+xml", + + ".zip": "application/zip", + ".gz": "application/gzip", + ".tgz": "application/gzip", + ".tar": "application/x-tar", + ".bz2": "application/x-bzip2", + ".xz": "application/x-xz", + ".zst": "application/zstd", + ".7z": "application/x-7z-compressed", + ".rar": "application/vnd.rar", + ".jar": "application/java-archive", + ".bin": contentTypeOctetStream, + ".exe": contentTypeOctetStream, + ".dmg": contentTypeOctetStream, + ".iso": contentTypeOctetStream, + ".deb": "application/vnd.debian.binary-package", + ".rpm": "application/x-rpm", + ".apk": "application/vnd.android.package-archive", + ".epub": "application/epub+zip", + + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".odt": "application/vnd.oasis.opendocument.text", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odp": "application/vnd.oasis.opendocument.presentation", +} + +// typeByExtension resolves a Content-Type from configured overrides, then the +// builtin table, then the host's MIME database. It returns "" when none match. +func typeByExtension(name string, overrides map[string]string) string { + ext := strings.ToLower(path.Ext(name)) + if ext == "" { + return "" + } + if ct, ok := overrides[ext]; ok { + return ct + } + if ct, ok := builtinTypes[ext]; ok { + return ct + } + return mime.TypeByExtension(ext) +} + +// sniffType resolves a Content-Type from a file's leading bytes +func sniffType(head []byte) string { + if len(head) == 0 { + return contentTypeOctetStream + } + if len(head) > sniffLen { + head = head[:sniffLen] + } + return http.DetectContentType(head) +} + +// baseType returns a Content-Type without its parameters (e.g., charset) +func baseType(ct string) string { + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return strings.TrimSpace(ct) +} diff --git a/pkg/backends/static/mime_test.go b/pkg/backends/static/mime_test.go new file mode 100644 index 000000000..a71c54c17 --- /dev/null +++ b/pkg/backends/static/mime_test.go @@ -0,0 +1,59 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import "testing" + +func TestTypeByExtension(t *testing.T) { + overrides := map[string]string{".html": "text/x-override", ".custom": "text/x-custom"} + tests := []struct { + name, expected string + }{ + {"index.html", "text/x-override"}, + {"page.custom", "text/x-custom"}, + {"APP.JS", "text/javascript; charset=utf-8"}, + {"a/b/font.woff2", "font/woff2"}, + {"site.webmanifest", "application/manifest+json"}, + {"README", ""}, + {"file.unknown-extension", ""}, + } + for _, test := range tests { + if ct := typeByExtension(test.name, overrides); ct != test.expected { + t.Errorf("%s: expected %q got %q", test.name, test.expected, ct) + } + } +} + +func TestSniffType(t *testing.T) { + if ct := sniffType(nil); ct != contentTypeOctetStream { + t.Errorf("expected %q for no content, got %q", contentTypeOctetStream, ct) + } + long := make([]byte, sniffLen*2) + copy(long, "%PDF-") + if ct := sniffType(long); ct != "application/pdf" { + t.Errorf("expected application/pdf, got %q", ct) + } +} + +func TestBaseType(t *testing.T) { + if bt := baseType("text/html; charset=utf-8"); bt != "text/html" { + t.Errorf("expected text/html, got %q", bt) + } + if bt := baseType("image/png"); bt != "image/png" { + t.Errorf("expected image/png, got %q", bt) + } +} diff --git a/pkg/backends/static/options/defaults.go b/pkg/backends/static/options/defaults.go new file mode 100644 index 000000000..72d534d2e --- /dev/null +++ b/pkg/backends/static/options/defaults.go @@ -0,0 +1,32 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package options + +import "time" + +const ( + // DefaultDefaultFile is the file served when a directory is requested + DefaultDefaultFile = "index.html" + // DefaultMaxFileSizeBytes is the largest file held in the fileserver cache + DefaultMaxFileSizeBytes = 1024 * 1024 + // DefaultMaxSizeBytes is the total size of the fileserver cache + DefaultMaxSizeBytes = 128 * 1024 * 1024 + // DefaultMaxFiles is the most files held in the fileserver cache + DefaultMaxFiles = 10000 + // DefaultRevalidationInterval is how often held files are compared to disk + DefaultRevalidationInterval = 10 * time.Second +) diff --git a/pkg/backends/static/options/options.go b/pkg/backends/static/options/options.go new file mode 100644 index 000000000..3ca24497d --- /dev/null +++ b/pkg/backends/static/options/options.go @@ -0,0 +1,268 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package options provides the settings for the static file server backend. +package options + +import ( + "errors" + "fmt" + "maps" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "github.com/trickstercache/trickster/v2/pkg/parsing/timeconv" +) + +// Options configures a static file server backend +type Options struct { + // Root is the path of the directory holding the content to serve; it is required + Root string `yaml:"root,omitempty"` + // DefaultFile is the file served when a directory is requested; defaults to index.html + DefaultFile string `yaml:"default_file,omitempty"` + // CacheControl is the Cache-Control response header value; when empty (the default), the + // header is omitted and clients judge a file's freshness from its Last-Modified time + CacheControl string `yaml:"cache_control,omitempty"` + // CacheControlByExtension overrides CacheControl for files with the given extension (e.g., .js) + CacheControlByExtension map[string]string `yaml:"cache_control_by_extension,omitempty"` + // ResponseHeaders is a set of additional headers attached to every response + ResponseHeaders map[string]string `yaml:"response_headers,omitempty"` + // MIMETypes adds or overrides Content-Type values by file extension (e.g., .md) + MIMETypes map[string]string `yaml:"mime_types,omitempty"` + // NotFoundFile is a file, relative to Root, served in place of a plain 404 response + NotFoundFile string `yaml:"not_found_file,omitempty"` + // NotFoundStatus is the status NotFoundFile is served with: 404 (the default) for an + // error page, or 200 for a single-page application that routes on the client + NotFoundStatus int `yaml:"not_found_status,omitempty"` + // DirectoryListing, when true, lists a requested directory that has no DefaultFile + DirectoryListing bool `yaml:"directory_listing,omitempty"` + // FileserverCache configures the Fileserver cache of small files held in memory. It is + // named for what it is rather than for its key, as a backend has other caches. + FileserverCache *FileserverCacheOptions `yaml:"cache,omitempty"` +} + +// FileserverCacheOptions configures the Fileserver cache: small files held in memory +type FileserverCacheOptions struct { + // Disabled, when true, serves every request from disk + Disabled bool `yaml:"disabled,omitempty"` + // MaxFileSizeBytes is the largest file held in memory; larger files stream from disk + MaxFileSizeBytes int64 `yaml:"max_file_size_bytes,omitempty"` + // MaxSizeBytes caps the memory used by held files, counting a fixed overhead + // for each; files beyond it stream from disk + MaxSizeBytes int64 `yaml:"max_size_bytes,omitempty"` + // MaxFiles caps the number of files held, and with it the directories watched + MaxFiles int64 `yaml:"max_files,omitempty"` + // RevalidationInterval is how often held files are compared to disk, as a + // backstop to filesystem change events + RevalidationInterval timeconv.Duration `yaml:"revalidation_interval,omitempty"` +} + +// errors returned by Validate +var ( + ErrMissingRoot = errors.New("static.root is required") + ErrInvalidDefaultFile = errors.New("static.default_file must be a file name with no path and no leading '.'") + ErrInvalidNotFoundFile = errors.New("static.not_found_file must be a path within the root with no '.' segments") + ErrInvalidNotFoundCode = errors.New("static.not_found_status must be 404 or 200, and requires not_found_file") + ErrInvalidMaxFileSize = errors.New("static.cache.max_file_size_bytes must be greater than zero") + ErrInvalidMaxSize = errors.New("static.cache.max_size_bytes must not be less than max_file_size_bytes") + ErrInvalidMaxFiles = errors.New("static.cache.max_files must be greater than zero") + ErrInvalidRevalInterval = errors.New("static.cache.revalidation_interval must be greater than zero") +) + +// New returns the default static file server options +func New() *Options { + return &Options{ + DefaultFile: DefaultDefaultFile, + FileserverCache: NewFileserverCache(), + } +} + +// NewFileserverCache returns the default fileserver cache options +func NewFileserverCache() *FileserverCacheOptions { + return &FileserverCacheOptions{ + MaxFileSizeBytes: DefaultMaxFileSizeBytes, + MaxSizeBytes: DefaultMaxSizeBytes, + MaxFiles: DefaultMaxFiles, + RevalidationInterval: timeconv.Duration(DefaultRevalidationInterval), + } +} + +// Clone returns an independent copy +func (o *Options) Clone() *Options { + if o == nil { + return nil + } + out := *o + out.CacheControlByExtension = maps.Clone(o.CacheControlByExtension) + out.ResponseHeaders = maps.Clone(o.ResponseHeaders) + out.MIMETypes = maps.Clone(o.MIMETypes) + out.FileserverCache = o.FileserverCache.Clone() + return &out +} + +// Clone returns an independent copy +func (o *FileserverCacheOptions) Clone() *FileserverCacheOptions { + if o == nil { + return nil + } + out := *o + return &out +} + +// Initialize normalizes the options: the root becomes an absolute path and +// extension keys are lowercased with a leading dot +func (o *Options) Initialize() error { + if o == nil { + return nil + } + if o.DefaultFile == "" { + o.DefaultFile = DefaultDefaultFile + } + if o.FileserverCache == nil { + o.FileserverCache = NewFileserverCache() + } + if o.Root != "" { + root, err := filepath.Abs(o.Root) + if err != nil { + return fmt.Errorf("invalid static.root %q: %w", o.Root, err) + } + o.Root = root + } + o.CacheControlByExtension = normalizeExtensions(o.CacheControlByExtension) + o.MIMETypes = normalizeExtensions(o.MIMETypes) + if o.NotFoundFile != "" { + o.NotFoundFile = strings.TrimPrefix(path.Clean("/"+filepath.ToSlash(o.NotFoundFile)), "/") + if o.NotFoundStatus == 0 { + o.NotFoundStatus = http.StatusNotFound + } + } + return nil +} + +// NormalizeExtension lowercases an extension and ensures its leading dot +func NormalizeExtension(ext string) string { + ext = strings.ToLower(strings.TrimSpace(ext)) + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return ext +} + +func normalizeExtensions(in map[string]string) map[string]string { + if len(in) == 0 { + return in + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[NormalizeExtension(k)] = v + } + return out +} + +// Validate returns an error when the options cannot produce a working file server +func (o *Options) Validate() error { + if o == nil || strings.TrimSpace(o.Root) == "" { + return ErrMissingRoot + } + fi, err := os.Stat(o.Root) + if err != nil { + return fmt.Errorf("invalid static.root %q: %w", o.Root, err) + } + if !fi.IsDir() { + return fmt.Errorf("invalid static.root %q: not a directory", o.Root) + } + if o.DefaultFile == "" || strings.HasPrefix(o.DefaultFile, ".") || + strings.ContainsAny(o.DefaultFile, `/\`) { + return ErrInvalidDefaultFile + } + if o.NotFoundFile == "" && o.NotFoundStatus != 0 { + return ErrInvalidNotFoundCode + } + if o.NotFoundFile != "" { + if o.NotFoundStatus != http.StatusNotFound && o.NotFoundStatus != http.StatusOK { + return ErrInvalidNotFoundCode + } + if strings.HasPrefix(o.NotFoundFile, ".") || strings.Contains(o.NotFoundFile, "/.") || + strings.ContainsAny(o.NotFoundFile, "\\\x00") || strings.HasSuffix(o.NotFoundFile, "/") { + return ErrInvalidNotFoundFile + } + } + for ext, ct := range o.MIMETypes { + if ext == "" || ext == "." { + return fmt.Errorf("invalid static.mime_types extension %q", ext) + } + if _, _, err := mime.ParseMediaType(ct); err != nil { + return fmt.Errorf("invalid static.mime_types value for %q: %w", ext, err) + } + } + for ext := range o.CacheControlByExtension { + if ext == "" || ext == "." { + return fmt.Errorf("invalid static.cache_control_by_extension extension %q", ext) + } + } + for name := range o.ResponseHeaders { + if name == "" || http.CanonicalHeaderKey(name) == "" || strings.ContainsAny(name, " :\r\n") { + return fmt.Errorf("invalid static.response_headers name %q", name) + } + } + return o.FileserverCache.Validate() +} + +// Validate returns an error when the fileserver cache options are unusable +func (o *FileserverCacheOptions) Validate() error { + if o == nil || o.Disabled { + return nil + } + if o.MaxFileSizeBytes <= 0 { + return ErrInvalidMaxFileSize + } + if o.MaxSizeBytes < o.MaxFileSizeBytes { + return ErrInvalidMaxSize + } + if o.MaxFiles <= 0 { + return ErrInvalidMaxFiles + } + if o.RevalidationInterval <= 0 { + return ErrInvalidRevalInterval + } + return nil +} + +// UnmarshalYAML overlays explicitly configured fields onto the defaults +func (o *Options) UnmarshalYAML(unmarshal func(any) error) error { + type plain Options + value := plain(*New()) + if err := unmarshal(&value); err != nil { + return err + } + *o = Options(value) + return nil +} + +// UnmarshalYAML overlays explicitly configured fields onto the defaults +func (o *FileserverCacheOptions) UnmarshalYAML(unmarshal func(any) error) error { + type plain FileserverCacheOptions + value := plain(*NewFileserverCache()) + if err := unmarshal(&value); err != nil { + return err + } + *o = FileserverCacheOptions(value) + return nil +} diff --git a/pkg/backends/static/options/options_test.go b/pkg/backends/static/options/options_test.go new file mode 100644 index 000000000..3130fec98 --- /dev/null +++ b/pkg/backends/static/options/options_test.go @@ -0,0 +1,221 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package options + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/trickstercache/trickster/v2/pkg/parsing/timeconv" + + "go.yaml.in/yaml/v3" +) + +func TestNew(t *testing.T) { + o := New() + if o.DefaultFile != DefaultDefaultFile || o.CacheControl != "" || + o.DirectoryListing || o.FileserverCache == nil { + t.Error("expected default options with directory listing off") + } + mc := o.FileserverCache + if mc.Disabled || mc.MaxFileSizeBytes != DefaultMaxFileSizeBytes || + mc.MaxSizeBytes != DefaultMaxSizeBytes || mc.MaxFiles != DefaultMaxFiles || + time.Duration(mc.RevalidationInterval) != DefaultRevalidationInterval { + t.Error("expected default fileserver cache options") + } +} + +func TestClone(t *testing.T) { + var o *Options + if o.Clone() != nil { + t.Error("expected a nil clone of nil options") + } + var mc *FileserverCacheOptions + if mc.Clone() != nil { + t.Error("expected a nil clone of nil fileserver cache options") + } + o = New() + o.ResponseHeaders = map[string]string{"X-Test": "1"} + o.MIMETypes = map[string]string{".a": "text/plain"} + o.CacheControlByExtension = map[string]string{".a": "no-store"} + c := o.Clone() + c.ResponseHeaders["X-Test"] = "2" + c.MIMETypes[".a"] = "text/html" + c.CacheControlByExtension[".a"] = "private" + c.FileserverCache.Disabled = true + if o.ResponseHeaders["X-Test"] != "1" || o.MIMETypes[".a"] != "text/plain" || + o.CacheControlByExtension[".a"] != "no-store" || o.FileserverCache.Disabled { + t.Error("expected a clone to be independent of its source") + } +} + +func TestInitialize(t *testing.T) { + var o *Options + if err := o.Initialize(); err != nil { + t.Error(err) + } + o = &Options{ + Root: "relative/site", + MIMETypes: map[string]string{"MD": "text/markdown", ".Txt": "text/plain"}, + CacheControlByExtension: map[string]string{" js ": "no-store"}, + } + if err := o.Initialize(); err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(o.Root) || !strings.HasSuffix(o.Root, filepath.Join("relative", "site")) { + t.Errorf("expected an absolute root, got %q", o.Root) + } + if o.DefaultFile != DefaultDefaultFile || o.FileserverCache == nil { + t.Error("expected defaults for unset fields") + } + if o.MIMETypes[".md"] == "" || o.MIMETypes[".txt"] == "" || o.CacheControlByExtension[".js"] == "" { + t.Errorf("expected normalized extensions, got %v %v", o.MIMETypes, o.CacheControlByExtension) + } + for in, want := range map[string]string{ + "/errors/404.html": "errors/404.html", "index.html": "index.html", "a/../b//c.html": "b/c.html", + "../../etc/passwd": "etc/passwd", + } { + o = &Options{Root: "site", NotFoundFile: in} + if err := o.Initialize(); err != nil || o.NotFoundFile != want || o.NotFoundStatus != 404 { + t.Errorf("expected %q to normalize to %q with a 404, got %q %d", in, want, o.NotFoundFile, o.NotFoundStatus) + } + } + o = &Options{Root: "site", NotFoundFile: "index.html", NotFoundStatus: 200} + if err := o.Initialize(); err != nil || o.NotFoundStatus != 200 { + t.Errorf("expected a configured status to be kept, got %d", o.NotFoundStatus) + } + if NormalizeExtension("") != "" { + t.Error("expected an empty extension to stay empty") + } +} + +func TestValidate(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "file.txt") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + valid := func(mods ...func(*Options)) *Options { + o := New() + o.Root = root + for _, mod := range mods { + mod(o) + } + return o + } + var nilOptions *Options + if err := nilOptions.Validate(); !errors.Is(err, ErrMissingRoot) { + t.Errorf("expected ErrMissingRoot for nil options, got %v", err) + } + if err := valid().Validate(); err != nil { + t.Errorf("expected valid options, got %v", err) + } + if err := valid(func(o *Options) { + o.MIMETypes = map[string]string{".md": "text/markdown; charset=utf-8"} + o.CacheControlByExtension = map[string]string{".js": "no-store"} + o.ResponseHeaders = map[string]string{"X-Frame-Options": "DENY"} + o.FileserverCache = &FileserverCacheOptions{Disabled: true} + o.NotFoundFile, o.NotFoundStatus = "errors/404.html", 404 + }).Validate(); err != nil { + t.Errorf("expected valid options, got %v", err) + } + if err := valid(func(o *Options) { o.FileserverCache = nil }).Validate(); err != nil { + t.Errorf("expected nil fileserver cache options to be valid, got %v", err) + } + + tests := []struct { + name string + mod func(*Options) + expected error + }{ + {"empty root", func(o *Options) { o.Root = " " }, ErrMissingRoot}, + {"missing root", func(o *Options) { o.Root = filepath.Join(root, "missing") }, nil}, + {"root is a file", func(o *Options) { o.Root = file }, nil}, + {"empty default file", func(o *Options) { o.DefaultFile = "" }, ErrInvalidDefaultFile}, + {"dot default file", func(o *Options) { o.DefaultFile = ".index" }, ErrInvalidDefaultFile}, + {"default file path", func(o *Options) { o.DefaultFile = "a/index.html" }, ErrInvalidDefaultFile}, + {"default file backslash", func(o *Options) { o.DefaultFile = `a\index.html` }, ErrInvalidDefaultFile}, + {"empty mime extension", func(o *Options) { o.MIMETypes = map[string]string{"": "text/plain"} }, nil}, + {"invalid mime type", func(o *Options) { o.MIMETypes = map[string]string{".a": "not a type"} }, nil}, + {"dot cache control extension", func(o *Options) { + o.CacheControlByExtension = map[string]string{".": "no-store"} + }, nil}, + {"empty header name", func(o *Options) { o.ResponseHeaders = map[string]string{"": "x"} }, nil}, + {"invalid header name", func(o *Options) { o.ResponseHeaders = map[string]string{"X Bad": "x"} }, nil}, + {"not found status alone", func(o *Options) { o.NotFoundStatus = 200 }, ErrInvalidNotFoundCode}, + {"not found status", func(o *Options) { o.NotFoundFile, o.NotFoundStatus = "404.html", 500 }, ErrInvalidNotFoundCode}, + {"not found dotfile", func(o *Options) { o.NotFoundFile, o.NotFoundStatus = ".404.html", 404 }, ErrInvalidNotFoundFile}, + {"not found dot segment", func(o *Options) { o.NotFoundFile, o.NotFoundStatus = "a/.b/404.html", 404 }, ErrInvalidNotFoundFile}, + {"not found backslash", func(o *Options) { o.NotFoundFile, o.NotFoundStatus = `a\404.html`, 404 }, ErrInvalidNotFoundFile}, + {"max file size", func(o *Options) { o.FileserverCache.MaxFileSizeBytes = 0 }, ErrInvalidMaxFileSize}, + {"max size", func(o *Options) { o.FileserverCache.MaxSizeBytes = 1 }, ErrInvalidMaxSize}, + {"max files", func(o *Options) { o.FileserverCache.MaxFiles = 0 }, ErrInvalidMaxFiles}, + {"interval", func(o *Options) { o.FileserverCache.RevalidationInterval = 0 }, ErrInvalidRevalInterval}, + } + for _, test := range tests { + err := valid(test.mod).Validate() + if err == nil { + t.Errorf("%s: expected an error", test.name) + } else if test.expected != nil && !errors.Is(err, test.expected) { + t.Errorf("%s: expected %v got %v", test.name, test.expected, err) + } + } +} + +func TestUnmarshalYAML(t *testing.T) { + var o Options + err := yaml.Unmarshal([]byte(` +root: /var/www +cache_control: no-cache +directory_listing: true +cache: + max_file_size_bytes: 2048 +`), &o) + if err != nil { + t.Fatal(err) + } + if o.Root != "/var/www" || !o.DirectoryListing || o.DefaultFile != DefaultDefaultFile { + t.Errorf("expected configured values over defaults, got %+v", o) + } + if o.CacheControl != "no-cache" { + t.Errorf("expected the configured cache_control, got %q", o.CacheControl) + } + mc := o.FileserverCache + if mc.MaxFileSizeBytes != 2048 || mc.MaxSizeBytes != DefaultMaxSizeBytes || + mc.MaxFiles != DefaultMaxFiles || + mc.RevalidationInterval != timeconv.Duration(DefaultRevalidationInterval) { + t.Errorf("expected fileserver cache values over defaults, got %+v", mc) + } + + o = Options{} + if err = yaml.Unmarshal([]byte("root: /var/www\n"), &o); err != nil { + t.Fatal(err) + } + if o.CacheControl != "" || o.FileserverCache == nil { + t.Errorf("expected defaults for unset fields, got %+v", o) + } + if err = yaml.Unmarshal([]byte("root: [1]\n"), &o); err == nil { + t.Error("expected an error for a mistyped root") + } + if err = yaml.Unmarshal([]byte("cache:\n disabled: [1]\n"), &o); err == nil { + t.Error("expected an error for a mistyped fileserver cache field") + } +} diff --git a/pkg/backends/static/series.go b/pkg/backends/static/series.go new file mode 100644 index 000000000..b51d00390 --- /dev/null +++ b/pkg/backends/static/series.go @@ -0,0 +1,103 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "sync" + + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/observability/keys" + "github.com/trickstercache/trickster/v2/pkg/observability/metrics" + + "github.com/prometheus/client_golang/prometheus" +) + +// A backend's series are published only once it is in service, as one is also built just to validate a +// configuration. They carry over a reload that keeps the name, and are deleted after one that doesn't. + +// responseSeries is a backend's response counters, by cache status and rendition +type responseSeries [numCacheStatuses][]prometheus.Counter + +// newResponseSeries resolves every counter once, keeping label lookups off the request path +func newResponseSeries(resolve func(status, encoding string) prometheus.Counter) *responseSeries { + var rs responseSeries + for status, label := range cacheStatusLabels { + rs[status] = make([]prometheus.Counter, maxRendition+1) + for _, enc := range append([]providers.Provider{providers.Identity}, renditions...) { + rs[status][enc] = resolve(label, encodingLabel(enc)) + } + } + return &rs +} + +// unpublished returns a counter that belongs to no backend, and is never exported +func unpublished() prometheus.Counter { + return prometheus.NewCounter(prometheus.CounterOpts{ + Name: "unpublished_total", + Help: "Count kept by a static backend that is not in service. It is never registered.", + }) +} + +func unpublishedResponses() *responseSeries { + return newResponseSeries(func(string, string) prometheus.Counter { return unpublished() }) +} + +func publishedResponses(backend string) *responseSeries { + return newResponseSeries(func(status, encoding string) prometheus.Counter { + return metrics.FileserverResponses.WithLabelValues(backend, status, encoding) + }) +} + +// seriesNames records which backend names have series in service, and which have been let +// go of and are waiting to see whether a replacement of the same name takes them over +var seriesNames = struct { + mtx sync.Mutex + owners map[string]*server + released map[string]struct{} +}{owners: make(map[string]*server), released: make(map[string]struct{})} + +// claimSeries puts a name's series in service, taking over any that were let go of +func claimSeries(name string, s *server) { + seriesNames.mtx.Lock() + defer seriesNames.mtx.Unlock() + seriesNames.owners[name] = s + delete(seriesNames.released, name) +} + +// releaseSeries lets go of a name's series, unless a replacement has them already +func releaseSeries(name string, s *server) { + seriesNames.mtx.Lock() + defer seriesNames.mtx.Unlock() + if seriesNames.owners[name] == s { + delete(seriesNames.owners, name) + seriesNames.released[name] = struct{}{} + } +} + +// sweepSeries deletes the series of every name that was let go of and not taken over, which +// is a backend that a reload removed or renamed. It is called once a reload's backends are in +// service, by when any of the same name has claimed them. +func sweepSeries() { + seriesNames.mtx.Lock() + defer seriesNames.mtx.Unlock() + for name := range seriesNames.released { + labels := prometheus.Labels{keys.Backend_Name: name} + metrics.FileserverResponses.DeletePartialMatch(labels) + metrics.FileserverCacheEvents.DeletePartialMatch(labels) + delete(seriesNames.released, name) + } +} diff --git a/pkg/backends/static/series_test.go b/pkg/backends/static/series_test.go new file mode 100644 index 000000000..a203de517 --- /dev/null +++ b/pkg/backends/static/series_test.go @@ -0,0 +1,192 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "net/http" + "path/filepath" + "testing" + + "github.com/trickstercache/trickster/v2/pkg/backends" + "github.com/trickstercache/trickster/v2/pkg/observability/keys" + "github.com/trickstercache/trickster/v2/pkg/observability/metrics" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + dto "github.com/prometheus/client_model/go" +) + +// seriesOf counts the series a vector exports for a backend, without creating any +func seriesOf(t *testing.T, vec prometheus.Collector, backend string) int { + t.Helper() + ch := make(chan prometheus.Metric, 64) + go func() { + vec.Collect(ch) + close(ch) + }() + var n int + for m := range ch { + var out dto.Metric + if err := m.Write(&out); err != nil { + t.Fatal(err) + } + for _, label := range out.GetLabel() { + if label.GetName() == keys.Backend_Name && label.GetValue() == backend { + n++ + } + } + } + return n +} + +// every vector a static backend publishes to, and how many series it has in each once in service +var seriesVectors = []struct { + name string + vec prometheus.Collector + n int +}{ + {"responses", metrics.FileserverResponses, int(numCacheStatuses) * (len(renditions) + 1)}, + {"cache events", metrics.FileserverCacheEvents, 2}, + {"usage objects", metrics.FileserverCacheObjects, 1}, + {"usage bytes", metrics.FileserverCacheBytes, 1}, + {"max objects", metrics.FileserverCacheMaxObjects, 1}, + {"max bytes", metrics.FileserverCacheMaxBytes, 1}, +} + +func requireSeries(t *testing.T, backend string, published bool, when string) { + t.Helper() + for _, v := range seriesVectors { + want := 0 + if published { + want = v.n + } + if got := seriesOf(t, v.vec, backend); got != want { + t.Errorf("%s: expected %d %s series for %s, got %d", when, want, v.name, backend, got) + } + } +} + +func newSeriesTestClient(t *testing.T, name, root string) *Client { + t.Helper() + b, err := NewClient(name, testBackendOptions(root), nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + c := b.(*Client) + t.Cleanup(c.Stop) + return c +} + +func TestSeriesOfARejectedConfigurationAreNeverPublished(t *testing.T) { + root := newTestSite(t) + name := testName(t) + // built to be validated, it serves (as a dry run's routes may be exercised) but is never started + c := newSeriesTestClient(t, name, root) + if resp := get(t, c.handler, http.MethodGet, "/"); resp.StatusCode != http.StatusOK { + t.Fatalf("expected the file, got %d", resp.StatusCode) + } + requireSeries(t, name, false, "never started") + // nor does a server that couldn't be built at all leave anything behind + if _, err := NewClient(name, testBackendOptions(filepath.Join(root, "missing")), nil, nil, nil, nil); err == nil { + t.Fatal("expected an error for a missing root") + } + requireSeries(t, name, false, "failed to build") +} + +func TestSeriesFollowABackendThroughReloads(t *testing.T) { + root := newTestSite(t) + name, renamed := testName(t), testName(t) + hits := func(backend string) float64 { + return testutil.ToFloat64(metrics.FileserverResponses.WithLabelValues(backend, "hit", identityLabel)) + } + serve := func(c *Client, n int) { + for range n { + get(t, c.handler, http.MethodGet, "/") + // made away from the request, and this client isn't one the helper knows to wait for + c.server.stores.Wait() + } + } + + first := newSeriesTestClient(t, name, root) + StartClients(backends.Backends{name: first}) + requireSeries(t, name, true, "in service") + serve(first, 4) + if hits(name) != 3 { + t.Fatalf("expected 3 hits after the first load, got %v", hits(name)) + } + + // a reload that keeps the name: the old is stopped, the new started, and the counters carry on + second := newSeriesTestClient(t, name, root) + StopClients(backends.Backends{name: first}) + StartClients(backends.Backends{name: second}) + requireSeries(t, name, true, "reloaded under the same name") + if hits(name) != 3 { + t.Errorf("expected the counters to carry over the reload, got %v", hits(name)) + } + serve(second, 3) + // a request still draining through the old server counts to the same series + get(t, first.handler, http.MethodGet, "/nope") + if hits(name) != 5 { + t.Errorf("expected the replacement to count on from where the last left off, got %v", hits(name)) + } + // stopping the old one again, as a rollback's cleanup may, doesn't take the replacement's away + first.Stop() + StartClients(backends.Backends{name: second}) + requireSeries(t, name, true, "after the replaced server was stopped again") + + // a reload that renames the backend: the old name's series go, and the new name's appear + third := newSeriesTestClient(t, renamed, root) + StopClients(backends.Backends{name: second}) + StartClients(backends.Backends{renamed: third}) + requireSeries(t, name, false, "renamed away") + requireSeries(t, renamed, true, "renamed to") + + // a reload that removes it, leaving a configuration with no static backends at all + StopClients(backends.Backends{renamed: third}) + StartClients(backends.Backends{}) + requireSeries(t, renamed, false, "removed") + + // a rollback puts the old clients back into service, and their series with them + StartClients(backends.Backends{renamed: third}) + requireSeries(t, renamed, true, "rolled back") +} + +func TestSeriesOfABackendWithoutACache(t *testing.T) { + root := newTestSite(t) + name := testName(t) + o := testBackendOptions(root) + o.Static.FileserverCache.Disabled = true + b, err := NewClient(name, o, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + c := b.(*Client) + StartClients(backends.Backends{name: c}) + get(t, c.handler, http.MethodGet, "/") + // it still serves files, which are counted, but has no cache to report on + if got := seriesOf(t, metrics.FileserverResponses, name); got != seriesVectors[0].n { + t.Errorf("expected the response series of a backend with no cache, got %d", got) + } + for _, v := range seriesVectors[1:] { + if got := seriesOf(t, v.vec, name); got != 0 { + t.Errorf("expected no %s series without a cache, got %d", v.name, got) + } + } + StopClients(backends.Backends{name: c}) + StartClients(backends.Backends{}) + requireSeries(t, name, false, "removed") +} diff --git a/pkg/backends/static/server.go b/pkg/backends/static/server.go new file mode 100644 index 000000000..ab44cca04 --- /dev/null +++ b/pkg/backends/static/server.go @@ -0,0 +1,759 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + so "github.com/trickstercache/trickster/v2/pkg/backends/static/options" + "github.com/trickstercache/trickster/v2/pkg/encoding/profile" + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/observability/keys" + "github.com/trickstercache/trickster/v2/pkg/observability/logging" + "github.com/trickstercache/trickster/v2/pkg/observability/logging/logger" + "github.com/trickstercache/trickster/v2/pkg/proxy/headers" + "github.com/trickstercache/trickster/v2/pkg/util/safego" + "github.com/trickstercache/trickster/v2/pkg/util/sets" + "github.com/trickstercache/trickster/v2/pkg/watchers/filesystem" + + "golang.org/x/sync/singleflight" +) + +const ( + allowedMethods = "GET, HEAD, OPTIONS" + rootName = "." +) + +// ErrMissingOptions is returned when a file server is built without its options +var ErrMissingOptions = errors.New("static backend requires the static options block") + +// rootHandle pairs the open content root with the identity it was opened at, +// so a root that is later replaced on disk (e.g., a symlink swap) is detected. +type rootHandle struct { + root *os.Root + info os.FileInfo +} + +// server is the file server. All file access goes through an os.Root, which +// refuses any path (including via symlinks) that resolves outside the root. +type server struct { + name string + opts *so.Options + compressible sets.Set[string] + root atomic.Pointer[rootHandle] + cache *fileCache + watcher *filesystem.DirWatcher + // loads collapses concurrent loads of one file, or encodings of one rendition, into one + loads singleflight.Group + // responses is the backend's response counters, which are private until it is in service + responses atomic.Pointer[responseSeries] + // stores counts the cache stores still to be made, which are made off the request path, + // and started every one that has been + stores sync.WaitGroup + started atomic.Int64 +} + +func newServer(name string, o *so.Options, compressible sets.Set[string]) (*server, error) { + if o == nil { + return nil, ErrMissingOptions + } + s := &server{name: name, opts: o, compressible: compressible} + s.responses.Store(unpublishedResponses()) + if err := s.openRoot(); err != nil { + return nil, err + } + if mc := o.FileserverCache; mc != nil && !mc.Disabled { + w, err := filesystem.NewDirWatcher(&filesystem.DirOptions{ + Name: "static:" + name, + Interval: time.Duration(mc.RevalidationInterval), + OnEvent: s.onFileEvent, + OnLost: func() { s.cache.purge() }, + OnInterval: s.revalidate, + }) + if err != nil { + return nil, err + } + s.watcher = w + s.cache = newFileCache(name, mc.MaxFileSizeBytes, mc.MaxSizeBytes, mc.MaxFiles, w) + } + return s, nil +} + +func (s *server) openRoot() error { + root, err := os.OpenRoot(s.opts.Root) + if err != nil { + return err + } + info, err := root.Stat(rootName) + if err != nil { + return errors.Join(err, root.Close()) + } + // a replaced handle is not closed here as requests may still be using it; + // its finalizer releases it once they are done + s.root.Store(&rootHandle{root: root, info: info}) + return nil +} + +// start puts the server in service: its series are published, and its fileserver cache +// begins holding files and watching for changes to them +func (s *server) start() { + claimSeries(s.name, s) + s.responses.Store(publishedResponses(s.name)) + if s.watcher == nil { + return + } + s.cache.activate() + s.watcher.Start() +} + +// stop takes the server out of service; requests still in flight are served from disk +func (s *server) stop() { + releaseSeries(s.name, s) + if s.watcher == nil { + return + } + s.cache.retire() + s.watcher.Close() + s.cache.purge() +} + +// counted records a response in the backend's metrics +func (s *server) counted(status cacheStatus, enc providers.Provider) { + s.responses.Load()[status][enc].Inc() +} + +func (s *server) onFileEvent(name string) { + rel, err := filepath.Rel(s.opts.Root, name) + if err != nil || rel == rootName || strings.HasPrefix(rel, "..") { + s.cache.purge() + return + } + // a file costs a lookup; only a directory that holds files has a subtree to drop + s.cache.invalidatePath(filepath.ToSlash(rel)) +} + +// revalidate is the backstop for changes that raise no event, such as a +// replaced root, a changed symlink target or a filesystem without events. +func (s *server) revalidate() { + rh := s.root.Load() + if fi, err := os.Stat(s.opts.Root); err == nil && !os.SameFile(rh.info, fi) { + if err = s.openRoot(); err == nil { + s.cache.purge() + rh = s.root.Load() + } + } + s.cache.revalidate(func(key string, e *entry) bool { + fi, err := rh.root.Stat(key) + return err != nil || !sameFile(e.info, fi) + }) +} + +// sameFile reports whether b is the file a described, unchanged: the same +// identity, size and modification time, which are also all the ETag reflects +func sameFile(a, b os.FileInfo) bool { + return a.Size() == b.Size() && a.ModTime().Equal(b.ModTime()) && os.SameFile(a, b) +} + +// allowedDotSegments are the names exempt from the refusal of dotfiles, and only +// as a path's first segment. It is deliberately not configurable. +var allowedDotSegments = []string{".well-known"} + +func allowedDotSegment(p string, i int) bool { + if i != 0 { + return false + } + for _, name := range allowedDotSegments { + rest, ok := strings.CutPrefix(p[1:], name) + if ok && (rest == "" || rest[0] == '/') { + return true + } + } + return false +} + +// resolve maps a request path to a root-relative name, refusing dotfiles +func resolve(p string) (name string, dirRequest bool, ok bool) { + if p == "" || p[0] != '/' { + p = "/" + p + } + if strings.ContainsAny(p, "\\\x00") { + return "", false, false + } + dirRequest = p[len(p)-1] == '/' + p = path.Clean(p) + for i := range len(p) - 1 { + if p[i] == '/' && p[i+1] == '.' && !allowedDotSegment(p, i) { + return "", false, false + } + } + if name = p[1:]; name == "" { + name = rootName + } + return name, dirRequest, true +} + +func (s *server) defaultFileIn(dir string) string { + if dir == rootName { + return s.opts.DefaultFile + } + return dir + "/" + s.opts.DefaultFile +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead: + case http.MethodOptions: + w.Header().Set(headers.NameAllow, allowedMethods) + w.WriteHeader(http.StatusNoContent) + return + default: + w.Header().Set(headers.NameAllow, allowedMethods) + writeError(w, http.StatusMethodNotAllowed) + return + } + name, dirRequest, ok := resolve(r.URL.Path) + if !ok { + s.notFound(w, r, false) + return + } + key := name + if dirRequest { + key = s.defaultFileIn(name) + } + s.serveKey(w, r, key, dirRequest, false) +} + +// serveKey serves the file at key. fallback is true when key is the not-found +// file, which is answered with a plain 404 if it is itself absent. +func (s *server) serveKey(w http.ResponseWriter, r *http.Request, key string, dirRequest, fallback bool) { + accepted := acceptedEncodings(r) + // of the renditions that are held, the one the client most prefers + for i := range accepted.Len() { + if v := s.cache.get(key, accepted.At(i)); v != nil { + s.serveRendition(w, r, v) + return + } + } + if e := s.cache.get(key, providers.Identity); e != nil { + s.serveEntry(w, r, e, nil, accepted, statusHit) + return + } + // nothing of the file is held, so it is read from disk: a held rendition in an + // encoding the client didn't ask for is never decoded to stand in for the file + s.serveFromDisk(w, r, key, dirRequest, fallback, accepted) +} + +// serveEntry serves a file from memory, as a new rendition where the request is suited to +// one. pending is the file's own store where that is still to be made, and is nil otherwise. +func (s *server) serveEntry(w http.ResponseWriter, r *http.Request, e *entry, pending *reservation, + accepted providers.Accepted, status cacheStatus, +) { + // an encoding found not to shrink this file is passed over for the next the client accepts + unhelpful := e.unhelpful() + if usable := accepted.Filter(^unhelpful); s.wantsRendition(r, usable, e.compressible) { + if status == statusHit { + status = statusPartialHit + } + s.streamRendition(w, r, e, pending, usable.Preferred(), status) + return + } + s.storeEntry(e, pending) + withoutEncodings(r, unhelpful) + s.counted(status, providers.Identity) + s.serve(w, r, &e.fileMeta, bytes.NewReader(e.body)) +} + +// withoutEncodings keeps the response path from using encodings that are no use to a file, +// which it would otherwise do again on every request +func withoutEncodings(r *http.Request, unhelpful providers.Provider) { + if unhelpful == 0 { + return + } + if ep := profile.FromContext(r.Context()); ep != nil { + ep.Supported &^= unhelpful + } +} + +// wantsRendition reports whether a response is worth encoding and holding: one that +// sends the whole file. A conditional request may need no body, so it is left alone. +func (s *server) wantsRendition(r *http.Request, accepted providers.Accepted, compressible bool) bool { + if accepted.Len() == 0 || !compressible || r.Method != http.MethodGet { + return false + } + return !conditional(r) +} + +// conditional reports whether a request carries a precondition, and so may be answered +// without the body of what it asked for +func conditional(r *http.Request) bool { + h := r.Header + return h.Get(headers.NameIfNoneMatch) != "" || h.Get(headers.NameIfModifiedSince) != "" || + h.Get(headers.NameIfMatch) != "" || h.Get(headers.NameIfUnmodifiedSince) != "" +} + +// renditionHeaders describes an encoded response, and tells the response path that it +// is already encoded so that it is passed through as it is. +func renditionHeaders(w http.ResponseWriter, r *http.Request, m *fileMeta, enc providers.Provider) { + name := enc.String() + if ep := profile.FromContext(r.Context()); ep != nil { + ep.ContentEncoding = name + } + h := w.Header() + h.Set(headers.NameContentType, m.contentType) + if m.cacheControl != "" { + h.Set(headers.NameCacheControl, m.cacheControl) + } + h.Add(headers.NameVary, headers.NameAcceptEncoding) + h.Set(headers.NameContentEncoding, name) + // weak, as the validator describes the file as stored rather than these bytes + h.Set(headers.NameETag, m.weakETag) +} + +// serveRendition writes a held rendition +func (s *server) serveRendition(w http.ResponseWriter, r *http.Request, v *entry) { + s.counted(statusHit, v.encoding) + renditionHeaders(w, r, &v.fileMeta, v.encoding) + // ServeContent leaves the length off an encoded response, which would then be sent in + // chunks. It is known here, and is given unless a precondition may leave the body out. + if !conditional(r) { + w.Header().Set(headers.NameContentLength, strconv.Itoa(len(v.body))) + } + http.ServeContent(w, r, "", v.modTime, bytes.NewReader(v.body)) +} + +// streamRendition encodes a file straight to the response, keeping a copy of what it +// sends to hold as the rendition. Another request already doing so for the same rendition, +// or a cache without the room, leaves this one to encode for its own response alone. +func (s *server) streamRendition(w http.ResponseWriter, r *http.Request, e *entry, pending *reservation, + enc providers.Provider, status cacheStatus, +) { + s.counted(status, enc) + // reserved at the size of the file, which a rendition worth holding is smaller than + rsv := s.cache.reserve(e.key, enc, s.osDir(e.key), int64(len(e.body)), e) + // only now is the file's own store let go, as it would otherwise be what the rendition's + // reservation found the cache busy with; it isn't left until the response is sent, either + s.storeEntry(e, pending) + renditionHeaders(w, r, &e.fileMeta, enc) + if !e.modTime.IsZero() { + w.Header().Set(headers.NameLastModified, e.modTime.UTC().Format(http.TimeFormat)) + } + w.WriteHeader(http.StatusOK) + var dst io.Writer = w + var tee *teeBuffer + if rsv != nil { + tee = &teeBuffer{limit: len(e.body) - 1} + dst = io.MultiWriter(w, tee) + } + ew := newEncoder(enc, dst) + _, err := ew.Write(e.body) + if cerr := ew.Close(); err == nil { + err = cerr + } + if rsv == nil { + return + } + if err == nil && tee.overflow { + // this encoding is no use to the file; the others are still to be found out + e.unencodable.Or(uint32(enc)) + } + // the response is complete; what is left is bookkeeping, which it doesn't wait on + s.storeAsync(func() { + if err != nil || tee.overflow { + rsv.cancel() + return + } + // the file's own store comes first, as a rendition is only held alongside its file; + // it is usually made by now, and is made here if not + if pending != nil { + pending.commit(e) + } + // copied to size, as the buffer grew by doubling and would hold the excess too + body := make([]byte, len(tee.buf)) + copy(body, tee.buf) + rsv.commit(&entry{fileMeta: e.fileMeta, body: body, key: e.key, encoding: enc}) + }) +} + +// storeAsync runs a cache store away from the request that produced it, so that the +// response is never held up behind the cache's lock +func (s *server) storeAsync(store func()) { + s.stores.Add(1) + s.started.Add(1) + safego.Go(func(r any, stack []byte) { + logger.Error("static file cache store panic", logging.Pairs{ + keys.BackendName: s.name, "panic": r, "stack": string(stack), + }) + }, func() { + defer s.stores.Done() + store() + }) +} + +func (s *server) osDir(key string) string { + return filepath.Join(s.opts.Root, filepath.FromSlash(path.Dir(key))) +} + +// notFound answers a request for nothing that can be served, with the configured +// not-found file where there is one, at its configured status. +func (s *server) notFound(w http.ResponseWriter, r *http.Request, fallback bool) { + key := s.opts.NotFoundFile + if key == "" || fallback || (r.Method != http.MethodGet && r.Method != http.MethodHead) { + writeError(w, http.StatusNotFound) + return + } + if s.opts.NotFoundStatus == http.StatusOK { + // served as the page it is, since the application at it handles the request's path + s.serveKey(w, r, key, false, true) + return + } + // an error page is the same for every request, whatever the request asked of the missing file + r = r.Clone(r.Context()) + for _, name := range []string{ + headers.NameRange, headers.NameIfRange, headers.NameIfNoneMatch, headers.NameIfModifiedSince, + headers.NameIfMatch, headers.NameIfUnmodifiedSince, + } { + r.Header.Del(name) + } + s.serveKey(&errorPageWriter{ResponseWriter: w}, r, key, false, true) +} + +// errorPageWriter sends a file as the body of a 404, without the headers that +// would let it be revalidated, ranged over or reused as though it were the file. +type errorPageWriter struct { + http.ResponseWriter + wroteHeader bool +} + +func (e *errorPageWriter) WriteHeader(code int) { + if !e.wroteHeader { + e.wroteHeader = true + if code == http.StatusOK { + code = http.StatusNotFound + h := e.Header() + h.Del(headers.NameETag) + h.Del(headers.NameLastModified) + h.Del(headers.NameAcceptRanges) + h.Set(headers.NameCacheControl, headers.ValueNoCache) + } + } + e.ResponseWriter.WriteHeader(code) +} + +func (e *errorPageWriter) Write(b []byte) (int, error) { + if !e.wroteHeader { + e.WriteHeader(http.StatusOK) + } + return e.ResponseWriter.Write(b) +} + +func (e *errorPageWriter) Unwrap() http.ResponseWriter { + return e.ResponseWriter +} + +func (s *server) serveFromDisk(w http.ResponseWriter, r *http.Request, key string, dirRequest, fallback bool, + accepted providers.Accepted, +) { + root := s.root.Load().root + // checked before opening: opening a pipe would block until it had a writer + fi, err := root.Stat(key) + if err != nil { + s.fail(w, r, key, err, fallback) + return + } + if fi.IsDir() && !dirRequest && !fallback { + redirectToDir(w, r) + return + } + // a directory, device, pipe or socket is never content + if !fi.Mode().IsRegular() { + s.notFound(w, r, fallback) + return + } + f, err := root.Open(key) + if err != nil { + s.fail(w, r, key, err, fallback) + return + } + defer f.Close() + // the open file is the authority from here on, as the path may have been replaced + if fi, err = f.Stat(); err != nil || !fi.Mode().IsRegular() { + s.notFound(w, r, fallback) + return + } + ct := typeByExtension(key, s.opts.MIMETypes) + if ct == "" { + head := make([]byte, sniffLen) + n, _ := io.ReadFull(f, head) + if _, err = f.Seek(0, io.SeekStart); err != nil { + s.fail(w, r, key, err, fallback) + return + } + ct = sniffType(head[:n]) + } + m := s.newMeta(key, fi, ct) + // a file sent in part is not worth holding, and a large one is sent as it is + if r.Header.Get(headers.NameRange) != "" || !s.cache.admits(key, fi.Size()) { + s.counted(statusDisk, providers.Identity) + s.serve(w, r, &m, f) + return + } + // a rendition is made from the whole file, so a response suited to one loads it now + if m.size >= minRenditionSize && s.wantsRendition(r, accepted, m.compressible) { + if e, pending := s.load(root, f, &m, key); e != nil { + s.serveEntry(w, r, e, pending, accepted, statusMiss) + return + } + if _, err = f.Seek(0, io.SeekStart); err != nil { + s.fail(w, r, key, err, fallback) + return + } + } + if m.size < minRenditionSize { + withoutEncodings(r, providers.AllSupportedWebProvidersBitmap) + } + l := &lazyContent{s: s, root: root, f: f, meta: &m, key: key} + s.serve(w, r, &m, l) + if l.body != nil { + s.counted(statusMiss, providers.Identity) + return + } + s.counted(statusDisk, providers.Identity) +} + +// lazyContent defers reading a file until the response is known to need its +// body, which a HEAD request or an unmodified conditional one never does. +type lazyContent struct { + s *server + root *os.Root + f *os.File + meta *fileMeta + key string + + off int64 + body []byte + resolved bool +} + +func (l *lazyContent) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekCurrent: + offset += l.off + case io.SeekEnd: + offset += l.meta.size + } + if offset < 0 { + return 0, os.ErrInvalid + } + l.off = offset + if l.resolved && l.body == nil { + return l.f.Seek(offset, io.SeekStart) + } + return offset, nil +} + +func (l *lazyContent) Read(p []byte) (int, error) { + if !l.resolved { + l.resolved = true + if e, pending := l.s.load(l.root, l.f, l.meta, l.key); e != nil { + l.s.storeEntry(e, pending) + l.body = e.body + } else if _, err := l.f.Seek(l.off, io.SeekStart); err != nil { + return 0, err + } + } + if l.body == nil { + n, err := l.f.Read(p) + l.off += int64(n) + return n, err + } + if l.off >= int64(len(l.body)) { + return 0, io.EOF + } + n := copy(p, l.body[l.off:]) + l.off += int64(n) + return n, nil +} + +// loaded is the result of a load: the file's entry, and its store where still to be made +type loaded struct { + entry *entry + pending *reservation +} + +// load returns the file's entry, read from disk if it isn't held, and the entry's store if still to +// be made. Concurrent loads share one read. It returns nil when the file can't be held. +func (s *server) load(root *os.Root, f *os.File, m *fileMeta, key string) (*entry, *reservation) { + v, _, _ := s.loads.Do(key, func() (any, error) { + if e := s.cache.get(key, providers.Identity); e != nil { + return loaded{entry: e}, nil + } + return s.read(root, f, m, key), nil + }) + // the response headers describe this request's file, which a shared or + // previously held entry matches only if nothing changed in between + if l := v.(loaded); l.entry != nil && sameFile(l.entry.info, m.info) { + return l.entry, l.pending + } + return nil, nil +} + +func (s *server) read(root *os.Root, f *os.File, m *fileMeta, key string) loaded { + // capacity is reserved before the body is allocated, and the directory is + // watched before the file is read so that any later change raises an event + rsv := s.cache.reserve(key, providers.Identity, s.osDir(key), m.size, nil) + if rsv == nil { + return loaded{} + } + // the opened file may have been replaced on disk before the watch was armed + if cur, err := root.Stat(key); err != nil || !sameFile(m.info, cur) { + s.storeAsync(rsv.cancel) + return loaded{} + } + body := make([]byte, m.size) + if _, err := io.ReadFull(f, body); err != nil { + s.storeAsync(rsv.cancel) + return loaded{} + } + if cur, err := f.Stat(); err != nil || !sameFile(m.info, cur) { + s.storeAsync(rsv.cancel) + return loaded{} + } + e := &entry{fileMeta: *m, body: body, key: key} + if m.size < minRenditionSize { + e.unencodable.Store(uint32(providers.AllSupportedWebProvidersBitmap)) + } + // the caller decides when the store is made; the entry serves its request either way + return loaded{entry: e, pending: rsv} +} + +// storeEntry makes a loaded file's store, away from the request. Loads that shared one read +// each ask for it, and it is made, and started, only once. +func (s *server) storeEntry(e *entry, pending *reservation) { + // claimed before anything is started, so that a burst of loads that shared one read + // starts one store between them, rather than one each to find the store already made + if pending != nil && pending.claim() { + s.storeAsync(func() { pending.commit(e) }) + } +} + +func (s *server) newMeta(key string, fi os.FileInfo, ct string) fileMeta { + m := fileMeta{ + info: fi, + modTime: fi.ModTime(), + size: fi.Size(), + contentType: ct, + } + // the modification time and size, as other web servers use. Derived from metadata + // alone, it is the same from memory or disk and validates without reading the file. + tag := strconv.FormatInt(m.modTime.UnixNano(), 16) + "-" + strconv.FormatInt(m.size, 16) + m.etag = `"` + tag + `"` + m.weakETag = `W/"` + tag + `"` + m.compressible = s.compressible.Contains(baseType(m.contentType)) + m.cacheControl = s.opts.CacheControl + if cc, ok := s.opts.CacheControlByExtension[strings.ToLower(path.Ext(key))]; ok { + m.cacheControl = cc + } + return m +} + +// serve writes the file, leaving conditional and range handling to http.ServeContent +func (s *server) serve(w http.ResponseWriter, r *http.Request, m *fileMeta, content io.ReadSeeker) { + h := w.Header() + h.Set(headers.NameContentType, m.contentType) + if m.cacheControl != "" { + h.Set(headers.NameCacheControl, m.cacheControl) + } + etag := m.etag + if m.compressible { + h.Add(headers.NameVary, headers.NameAcceptEncoding) + // the response will be encoded on its way out, so the validator is weak + if ep := profile.FromContext(r.Context()); ep != nil && ep.Supported != 0 { + etag = m.weakETag + w = &codingGuard{ResponseWriter: w, profile: ep, etag: m.etag} + } + } + h.Set(headers.NameETag, etag) + http.ServeContent(w, r, "", m.modTime, content) +} + +// codingGuard keeps anything but a complete 200 response from being encoded +// downstream, as byte ranges and validators describe the file as stored. +type codingGuard struct { + http.ResponseWriter + profile *profile.Profile + etag string + wroteHeader bool +} + +func (g *codingGuard) WriteHeader(code int) { + if !g.wroteHeader { + g.wroteHeader = true + if code != http.StatusOK { + g.profile.Supported = 0 + } + if code == http.StatusPartialContent { + g.Header().Set(headers.NameETag, g.etag) + } + } + g.ResponseWriter.WriteHeader(code) +} + +func (g *codingGuard) Write(b []byte) (int, error) { + if !g.wroteHeader { + g.WriteHeader(http.StatusOK) + } + return g.ResponseWriter.Write(b) +} + +func (g *codingGuard) Unwrap() http.ResponseWriter { + return g.ResponseWriter +} + +// redirectToDir sends a directory requested without its trailing slash to the +// slashed form. The target is relative, so it holds under any routing prefix. +func redirectToDir(w http.ResponseWriter, r *http.Request) { + u := url.URL{Path: path.Base(r.URL.Path) + "/", RawQuery: r.URL.RawQuery} + w.Header().Set(headers.NameLocation, u.String()) + w.WriteHeader(http.StatusMovedPermanently) +} + +// fail answers a file that can't be opened. Absent, unreadable and +// out-of-root files all look the same to the client. +func (s *server) fail(w http.ResponseWriter, r *http.Request, key string, err error, fallback bool) { + if !errors.Is(err, os.ErrNotExist) { + logger.Debug("static file unavailable", logging.Pairs{ + keys.BackendName: s.name, keys.Path: key, keys.Detail: err.Error(), + }) + } + s.notFound(w, r, fallback) +} + +func writeError(w http.ResponseWriter, code int) { + http.Error(w, http.StatusText(code), code) +} diff --git a/pkg/backends/static/server_test.go b/pkg/backends/static/server_test.go new file mode 100644 index 000000000..732b66a84 --- /dev/null +++ b/pkg/backends/static/server_test.go @@ -0,0 +1,1740 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "bytes" + "crypto/rand" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + bo "github.com/trickstercache/trickster/v2/pkg/backends/options" + so "github.com/trickstercache/trickster/v2/pkg/backends/static/options" + "github.com/trickstercache/trickster/v2/pkg/encoding/brotli" + "github.com/trickstercache/trickster/v2/pkg/encoding/deflate" + "github.com/trickstercache/trickster/v2/pkg/encoding/gzip" + eh "github.com/trickstercache/trickster/v2/pkg/encoding/handler" + "github.com/trickstercache/trickster/v2/pkg/encoding/profile" + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" + "github.com/trickstercache/trickster/v2/pkg/encoding/zstd" + "github.com/trickstercache/trickster/v2/pkg/proxy/headers" + "github.com/trickstercache/trickster/v2/pkg/util/sets" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +const ( + testHome = "

home

" + testDocs = "

docs

" + testCSS = "body{}" + + testCacheControl = "no-cache" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// replaceFile swaps a file in atomically, so no reader sees partial content +func replaceFile(t *testing.T, path, content string) { + t.Helper() + tmp := filepath.Join(filepath.Dir(filepath.Dir(path)), "tmp-"+filepath.Base(path)) + writeFile(t, tmp, content) + if err := os.Rename(tmp, path); err != nil { + t.Fatal(err) + } +} + +func newTestSite(t *testing.T) string { + t.Helper() + // resolved, so paths reported by the watcher match the configured root + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root = filepath.Join(root, "site") + writeFile(t, filepath.Join(root, "index.html"), testHome) + writeFile(t, filepath.Join(root, "app.css"), testCSS) + writeFile(t, filepath.Join(root, "docs", "index.html"), testDocs) + writeFile(t, filepath.Join(root, "docs", "guide.txt"), "guide") + writeFile(t, filepath.Join(root, "empty", "a.txt"), "a") + writeFile(t, filepath.Join(root, ".env"), "SECRET=1") + writeFile(t, filepath.Join(root, ".git", "config"), "secret") + writeFile(t, filepath.Join(root, "docs", ".hidden"), "secret") + return root +} + +func testOptions(root string) *so.Options { + o := so.New() + o.Root = root + o.FileserverCache.RevalidationInterval = 0 + return o +} + +// newTestServer returns a server that is never started unless the caller +// does so; one with a zero revalidation interval gets a test-sized one. +func newTestServer(t *testing.T, o *so.Options) *server { + t.Helper() + if o.FileserverCache != nil && o.FileserverCache.RevalidationInterval <= 0 { + o.FileserverCache.RevalidationInterval = 10 * 1000 * 1000 * 60 * 60 // 10h + } + s, err := newServer(testName(t), o, sets.New(bo.DefaultCompressibleTypes())) + if err != nil { + t.Fatal(err) + } + testServers.Store(s, struct{}{}) + t.Cleanup(func() { + s.stop() + testServers.Delete(s) + // left to a finalizer in service, where requests may still be using it; here nothing is, + // and a run of many tests would otherwise end with thousands of them still open + s.root.Load().root.Close() + }) + return s +} + +// testServers is the servers under test. Their cache stores are made away from the request +// that caused them, so requests made through get wait for them before the test looks. +var testServers sync.Map + +func awaitStores() { + testServers.Range(func(s, _ any) bool { + s.(*server).stores.Wait() + return true + }) +} + +func get(t *testing.T, h http.Handler, method, target string, hdrs ...string) *http.Response { + t.Helper() + r := httptest.NewRequest(method, "http://example.com/", nil) + // assigned rather than parsed, so paths a client would normalize arrive as written + r.URL.Path, r.URL.RawQuery, _ = strings.Cut(target, "?") + for i := 0; i+1 < len(hdrs); i += 2 { + r.Header.Set(hdrs[i], hdrs[i+1]) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + awaitStores() + return w.Result() +} + +func body(t *testing.T, resp *http.Response) string { + t.Helper() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func waitFor(timeout time.Duration, condition func() bool) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return true + } + time.Sleep(5 * time.Millisecond) + } + return condition() +} + +func TestNewServerErrors(t *testing.T) { + if _, err := newServer("test", nil, nil); err != ErrMissingOptions { + t.Errorf("expected ErrMissingOptions, got %v", err) + } + o := testOptions(filepath.Join(t.TempDir(), "missing")) + if _, err := newServer("test", o, nil); err == nil { + t.Error("expected an error for a missing root") + } + // a zero interval is rejected by the watcher + o = testOptions(t.TempDir()) + if _, err := newServer("test", o, nil); err == nil { + t.Error("expected an error for an invalid revalidation interval") + } +} + +func TestResolve(t *testing.T) { + tests := []struct { + path, name string + dir, ok bool + }{ + {"/", ".", true, true}, + {"", ".", true, true}, + {"/index.html", "index.html", false, true}, + {"docs/", "docs", true, true}, + {"/docs/guide.txt", "docs/guide.txt", false, true}, + {"/docs//guide.txt", "docs/guide.txt", false, true}, + {"/docs/../app.css", "app.css", false, true}, + {"/../../etc/passwd", "etc/passwd", false, true}, + {"/.env", "", false, false}, + {"/.git/config", "", false, false}, + {"/docs/.hidden", "", false, false}, + {"/docs/../.env", "", false, false}, + // the one name exempt from the refusal of dotfiles, and only at the top + {"/.well-known/security.txt", ".well-known/security.txt", false, true}, + {"/.well-known", ".well-known", false, true}, + {"/.well-known/", ".well-known", true, true}, + {"/.well-known/acme-challenge/token", ".well-known/acme-challenge/token", false, true}, + {"/.well-known/.secret", "", false, false}, + {"/.well-known/x/.secret", "", false, false}, + {"/docs/.well-known/security.txt", "", false, false}, + {"/.well-knownx/security.txt", "", false, false}, + {"/.well-know", "", false, false}, + {"/docs\\..\\.env", "", false, false}, + {"/docs\x00.html", "", false, false}, + {"/...", "", false, false}, + } + for _, test := range tests { + name, dir, ok := resolve(test.path) + if name != test.name || dir != test.dir || ok != test.ok { + t.Errorf("resolve(%q) = (%q, %t, %t), expected (%q, %t, %t)", + test.path, name, dir, ok, test.name, test.dir, test.ok) + } + } +} + +func TestServeStatus(t *testing.T) { + root := newTestSite(t) + outside := filepath.Join(filepath.Dir(root), "outside.txt") + writeFile(t, outside, "outside") + if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil { + t.Fatal(err) + } + // a relative link within the root is followed; an absolute one never is + if err := os.Symlink("app.css", filepath.Join(root, "link.css")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "app.css"), filepath.Join(root, "abs.css")); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "dirdefault", "index.html", "x.txt"), "x") + + for _, started := range []bool{false, true} { + s := newTestServer(t, testOptions(root)) + if started { + s.start() + } + tests := []struct { + method, target string + status int + body, location string + }{ + {http.MethodGet, "/", http.StatusOK, testHome, ""}, + {http.MethodGet, "/index.html", http.StatusOK, testHome, ""}, + {http.MethodGet, "/docs/", http.StatusOK, testDocs, ""}, + {http.MethodGet, "/docs/guide.txt", http.StatusOK, "guide", ""}, + {http.MethodGet, "/link.css", http.StatusOK, testCSS, ""}, + {http.MethodHead, "/app.css", http.StatusOK, "", ""}, + {http.MethodGet, "/docs", http.StatusMovedPermanently, "", "docs/"}, + {http.MethodGet, "/docs?a=b", http.StatusMovedPermanently, "", "docs/?a=b"}, + // a directory without the default file is absent, not forbidden + {http.MethodGet, "/empty/", http.StatusNotFound, "", ""}, + {http.MethodGet, "/dirdefault/", http.StatusNotFound, "", ""}, + {http.MethodGet, "/nope", http.StatusNotFound, "", ""}, + {http.MethodGet, "/nope/", http.StatusNotFound, "", ""}, + {http.MethodGet, "/app.css/", http.StatusNotFound, "", ""}, + {http.MethodGet, "/.env", http.StatusNotFound, "", ""}, + {http.MethodGet, "/.git/config", http.StatusNotFound, "", ""}, + {http.MethodGet, "/docs/.hidden", http.StatusNotFound, "", ""}, + {http.MethodGet, "/docs/../.env", http.StatusNotFound, "", ""}, + {http.MethodGet, "/../outside.txt", http.StatusNotFound, "", ""}, + {http.MethodGet, "/escape.txt", http.StatusNotFound, "", ""}, + {http.MethodGet, "/abs.css", http.StatusNotFound, "", ""}, + {http.MethodPost, "/", http.StatusMethodNotAllowed, "", ""}, + {http.MethodDelete, "/app.css", http.StatusMethodNotAllowed, "", ""}, + {http.MethodOptions, "/", http.StatusNoContent, "", ""}, + } + // twice, so a started server answers from disk and then from memory + for range 2 { + for _, test := range tests { + resp := get(t, s, test.method, test.target) + if resp.StatusCode != test.status { + t.Errorf("%s %s (started=%t): expected %d got %d", + test.method, test.target, started, test.status, resp.StatusCode) + continue + } + if b := body(t, resp); test.status == http.StatusOK && b != test.body { + t.Errorf("%s %s: expected body %q got %q", test.method, test.target, test.body, b) + } + if loc := resp.Header.Get(headers.NameLocation); loc != test.location { + t.Errorf("%s %s: expected location %q got %q", test.method, test.target, test.location, loc) + } + if test.status == http.StatusMethodNotAllowed || test.status == http.StatusNoContent { + if resp.Header.Get(headers.NameAllow) != allowedMethods { + t.Errorf("%s %s: expected an Allow header", test.method, test.target) + } + } + } + } + if started && s.cache.count() == 0 { + t.Error("expected a started server to hold files in memory") + } + if !started && s.cache.count() != 0 { + t.Error("expected a server that was never started to hold nothing") + } + } +} + +func TestServeHeaders(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "page.custom"), "custom") + writeFile(t, filepath.Join(root, "README"), "sniffed") + writeFile(t, filepath.Join(root, "blank"), "") + writeFile(t, filepath.Join(root, "font.woff2"), "font") + writeFile(t, filepath.Join(root, "UPPER.CSS"), testCSS) + o := testOptions(root) + o.CacheControl = testCacheControl + o.CacheControlByExtension = map[string]string{"CSS": "public, max-age=60"} + o.MIMETypes = map[string]string{"custom": "text/x-custom"} + if err := o.Initialize(); err != nil { + t.Fatal(err) + } + s := newTestServer(t, o) + s.start() + + tests := []struct { + target, contentType, cacheControl string + }{ + {"/", "text/html; charset=utf-8", testCacheControl}, + {"/app.css", "text/css; charset=utf-8", "public, max-age=60"}, + {"/UPPER.CSS", "text/css; charset=utf-8", "public, max-age=60"}, + {"/font.woff2", "font/woff2", testCacheControl}, + {"/page.custom", "text/x-custom", testCacheControl}, + {"/README", "text/html; charset=utf-8", testCacheControl}, + {"/blank", contentTypeOctetStream, testCacheControl}, + } + for range 2 { + for _, test := range tests { + resp := get(t, s, http.MethodGet, test.target) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s: expected 200 got %d", test.target, resp.StatusCode) + } + if ct := resp.Header.Get(headers.NameContentType); ct != test.contentType { + t.Errorf("%s: expected content type %q got %q", test.target, test.contentType, ct) + } + if cc := resp.Header.Get(headers.NameCacheControl); cc != test.cacheControl { + t.Errorf("%s: expected cache control %q got %q", test.target, test.cacheControl, cc) + } + etag := resp.Header.Get(headers.NameETag) + if !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) { + t.Errorf("%s: expected a strong etag, got %q", test.target, etag) + } + if resp.Header.Get(headers.NameLastModified) == "" { + t.Errorf("%s: expected a Last-Modified header", test.target) + } + } + } + + // by default the header is left out, and clients judge freshness for themselves + s = newTestServer(t, testOptions(root)) + for _, target := range []string{"/", "/app.css"} { + resp := get(t, s, http.MethodGet, target) + if _, ok := resp.Header[headers.NameCacheControl]; ok { + t.Errorf("%s: expected no Cache-Control header by default", target) + } + } +} + +func TestServeConditionalAndRanges(t *testing.T) { + root := newTestSite(t) + for _, started := range []bool{false, true} { + s := newTestServer(t, testOptions(root)) + if started { + s.start() + } + resp := get(t, s, http.MethodGet, "/") + etag := resp.Header.Get(headers.NameETag) + modified := resp.Header.Get(headers.NameLastModified) + + resp = get(t, s, http.MethodGet, "/", "If-None-Match", etag) + if resp.StatusCode != http.StatusNotModified || body(t, resp) != "" { + t.Errorf("expected an empty 304 for a matching etag, got %d", resp.StatusCode) + } + resp = get(t, s, http.MethodGet, "/", "If-None-Match", `"other"`) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 for a different etag, got %d", resp.StatusCode) + } + resp = get(t, s, http.MethodGet, "/", "If-Modified-Since", modified) + if resp.StatusCode != http.StatusNotModified { + t.Errorf("expected 304 for an unmodified file, got %d", resp.StatusCode) + } + resp = get(t, s, http.MethodGet, "/", "Range", "bytes=1-2") + if resp.StatusCode != http.StatusPartialContent || body(t, resp) != testHome[1:3] { + t.Errorf("expected a 206 with the requested bytes, got %d", resp.StatusCode) + } + resp = get(t, s, http.MethodGet, "/", "Range", "bytes=0-0,2-3") + if resp.StatusCode != http.StatusPartialContent || + !strings.HasPrefix(resp.Header.Get(headers.NameContentType), "multipart/byteranges") { + t.Errorf("expected a multipart 206, got %d", resp.StatusCode) + } + resp = get(t, s, http.MethodGet, "/", "Range", "bytes=500-") + if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { + t.Errorf("expected 416 for an unsatisfiable range, got %d", resp.StatusCode) + } + resp = get(t, s, http.MethodGet, "/", "Range", "bytes=1-2", "If-Range", `"other"`) + if resp.StatusCode != http.StatusOK || body(t, resp) != testHome { + t.Errorf("expected the whole file for a failed If-Range, got %d", resp.StatusCode) + } + } +} + +// compressibleCSS is large and repetitive enough that every encoding shrinks it +var compressibleCSS = strings.Repeat("body { color: red; margin: 0; padding: 0 }\n", 200) + +func decode(t *testing.T, enc string, b string) string { + t.Helper() + var out []byte + var err error + switch enc { + case "gzip": + out, err = gzip.Decode([]byte(b)) + case "br": + out, err = brotli.Decode([]byte(b)) + case "zstd": + out, err = zstd.Decode([]byte(b)) + case "deflate": + out, err = deflate.Decode([]byte(b)) + default: + return b + } + if err != nil { + t.Fatalf("unable to decode %s body: %v", enc, err) + } + return string(out) +} + +func responses(s *server, status cacheStatus, enc providers.Provider) float64 { + return testutil.ToFloat64(s.responses.Load()[status][enc]) +} + +func TestServeRenditions(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + writeFile(t, filepath.Join(root, "image.png"), strings.Repeat("png", 500)) + s := newTestServer(t, testOptions(root)) + s.start() + // behind the response path's own encoder, as it is when routed, to prove that + // a rendition is passed through it once rather than encoded again + h := eh.HandleCompression(s, s.compressible) + strong := get(t, h, http.MethodHead, "/big.css").Header.Get(headers.NameETag) + + // nothing is held: the file is read, encoded as Trickster prefers among what + // the client accepts, and both are held under their own keys + resp := get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip, deflate, br, zstd") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "zstd" { + t.Fatalf("expected the preferred encoding for a new rendition, got %q", enc) + } + if got := decode(t, "zstd", body(t, resp)); got != compressibleCSS { + t.Error("expected the rendition to decode to the file, having been encoded once") + } + if resp.Header.Get(headers.NameETag) != "W/"+strong || resp.Header.Get(headers.NameVary) != headers.NameAcceptEncoding { + t.Errorf("expected the weak validator and Vary on a rendition, got %v", resp.Header) + } + if s.cache.held("big.css") == nil || s.cache.get("big.css", providers.Zstandard) == nil || s.cache.count() != 2 { + t.Fatalf("expected the file and its rendition to be held, got %d entries", s.cache.count()) + } + if responses(s, statusMiss, providers.Zstandard) != 1 { + t.Error("expected a miss to be counted for the rendition that was served") + } + + // the file is held but not as the client accepts it: a rendition is made from memory + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "gzip" || + decode(t, enc, body(t, resp)) != compressibleCSS { + t.Errorf("expected a gzip rendition, got %q", enc) + } + if s.cache.get("big.css", providers.GZip) == nil || responses(s, statusPartialHit, providers.GZip) != 1 { + t.Error("expected the new rendition to be held, and counted as a partial hit") + } + + // of the renditions that are held (zstd and gzip), the one the client weights highest + // is served; among equal weights, or none, Trickster's own preference decides + for accept, want := range map[string]string{ + "gzip, deflate, br, zstd": "zstd", + "gzip;q=1.0, zstd;q=0.9": "gzip", + "br, zstd;q=0.5, gzip;q=0.4": "zstd", + "zstd;q=0, GZIP": "gzip", + "identity, x-unknown": "", + } { + before := s.cache.count() + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, accept) + if enc := resp.Header.Get(headers.NameContentEncoding); enc != want || + decode(t, enc, body(t, resp)) != compressibleCSS { + t.Errorf("Accept-Encoding %q: expected %q got %q", accept, want, enc) + } + if s.cache.count() != before { + t.Errorf("Accept-Encoding %q: expected a held rendition to be reused", accept) + } + } + if responses(s, statusHit, providers.GZip) != 2 || responses(s, statusHit, providers.Zstandard) != 2 || + responses(s, statusHit, providers.Identity) != 1 { + t.Error("expected hits to be counted by the rendition served") + } + // held in none of what the client accepts, a rendition is made in what it weights highest + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "deflate;q=0.2, br;q=0.7") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "br" || + decode(t, enc, body(t, resp)) != compressibleCSS || s.cache.get("big.css", providers.Brotli) == nil { + t.Errorf("expected a new rendition in the highest weighted encoding, got %q", enc) + } + + // a HEAD is answered from a held rendition, and a matching validator with a 304 + resp = get(t, h, http.MethodHead, "/big.css", headers.NameAcceptEncoding, "gzip") + if resp.Header.Get(headers.NameContentEncoding) != "gzip" || body(t, resp) != "" { + t.Error("expected a HEAD to describe the held rendition") + } + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip", "If-None-Match", strong) + if resp.StatusCode != http.StatusNotModified { + t.Errorf("expected a 304 for a rendition's validator, got %d", resp.StatusCode) + } + // byte ranges address the stored file, so a partial response is never a rendition + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip", "Range", "bytes=0-3") + if resp.StatusCode != http.StatusPartialContent || body(t, resp) != compressibleCSS[:4] || + resp.Header.Get(headers.NameContentEncoding) != "" || resp.Header.Get(headers.NameETag) != strong { + t.Errorf("expected an unencoded 206 with the strong validator, got %d %v", resp.StatusCode, resp.Header) + } + + // a change to the file takes every rendition along with it + replaceFile(t, filepath.Join(root, "big.css"), compressibleCSS+"/* v2 */") + if !waitFor(5*time.Second, func() bool { + resp := get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + return decode(t, resp.Header.Get(headers.NameContentEncoding), body(t, resp)) == compressibleCSS+"/* v2 */" + }) { + t.Error("expected the renditions of a changed file to be dropped") + } + + // a file of a type that isn't compressible has no renditions + resp = get(t, h, http.MethodGet, "/image.png", headers.NameAcceptEncoding, "gzip") + if resp.Header.Get(headers.NameContentEncoding) != "" || resp.Header.Get(headers.NameVary) != "" || + strings.HasPrefix(resp.Header.Get(headers.NameETag), "W/") || s.cache.get("image.png", providers.GZip) != nil { + t.Error("expected a non-compressible file to be served as stored") + } +} + +func TestRenditionsAreNotMadeForResponsesWithoutBodies(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + s := newTestServer(t, testOptions(root)) + s.start() + h := eh.HandleCompression(s, s.compressible) + etag := get(t, h, http.MethodHead, "/big.css", headers.NameAcceptEncoding, "gzip").Header.Get(headers.NameETag) + resp := get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip", "If-None-Match", etag) + if resp.StatusCode != http.StatusNotModified || s.cache.count() != 0 { + t.Errorf("expected a HEAD and a 304 to read and hold nothing, got %d with %d held", + resp.StatusCode, s.cache.count()) + } + // held as stored, a conditional request still doesn't have a rendition made for it + get(t, h, http.MethodGet, "/big.css") + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip", "If-None-Match", etag) + if resp.StatusCode != http.StatusNotModified || s.cache.count() != 1 { + t.Errorf("expected no rendition for a 304, got %d with %d held", resp.StatusCode, s.cache.count()) + } + // one that turns out to need the body is encoded by the response path instead + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip", "If-None-Match", `"other"`) + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "gzip" || + decode(t, enc, body(t, resp)) != compressibleCSS || s.cache.count() != 1 { + t.Errorf("expected the response path to encode it, got %q with %d held", enc, s.cache.count()) + } +} + +func TestUnencodableFilesAreServedAsStored(t *testing.T) { + s := newTestServer(t, testOptions(newTestSite(t))) + s.start() + h := eh.HandleCompression(s, s.compressible) + // too small for encoding to shrink, which is found out once and remembered + for range 3 { + resp := get(t, h, http.MethodGet, "/app.css", headers.NameAcceptEncoding, "gzip") + if resp.Header.Get(headers.NameContentEncoding) != "" || body(t, resp) != testCSS { + t.Fatalf("expected the file as stored, got encoding %q", resp.Header.Get(headers.NameContentEncoding)) + } + if strings.HasPrefix(resp.Header.Get(headers.NameETag), "W/") { + t.Error("expected the strong validator on a response that is not encoded") + } + } + if e := s.cache.held("app.css"); e == nil || e.unencodable.Load() == 0 || s.cache.count() != 1 { + t.Error("expected the file to be held, marked, and without renditions") + } + if s.cache.size.Load() != entryCost("app.css", int64(len(testCSS))) { + t.Error("expected the abandoned rendition's reservation to be released") + } +} + +func TestAcceptedEncodings(t *testing.T) { + list := func(r *http.Request) []providers.Provider { + a := acceptedEncodings(r) + out := make([]providers.Provider, 0, a.Len()) + for i := range a.Len() { + out = append(out, a.At(i)) + } + return out + } + request := func(method string, hdrs ...string) *http.Request { + r := httptest.NewRequest(method, "http://example.com/", nil) + for i := 0; i+1 < len(hdrs); i += 2 { + r.Header.Add(hdrs[i], hdrs[i+1]) + } + return r + } + ae := headers.NameAcceptEncoding + tests := []struct { + name string + request *http.Request + expected []providers.Provider + }{ + {"none", request(http.MethodGet), []providers.Provider{}}, + {"weighted", request(http.MethodGet, ae, "gzip, zstd;q=0.5, br;q=0.8"), + []providers.Provider{providers.GZip, providers.Brotli, providers.Zstandard}}, + {"unweighted", request(http.MethodGet, ae, "gzip, br"), []providers.Provider{providers.Brotli, providers.GZip}}, + {"head", request(http.MethodHead, ae, "gzip"), []providers.Provider{providers.GZip}}, + {"post", request(http.MethodPost, ae, "gzip"), []providers.Provider{}}, + {"range", request(http.MethodGet, ae, "gzip", headers.NameRange, "bytes=0-1"), []providers.Provider{}}, + } + for _, test := range tests { + if got := list(test.request); !slices.Equal(got, test.expected) { + t.Errorf("%s: expected %v got %v", test.name, test.expected, got) + } + } + // behind the response path, what it worked out is used, less what it has since ruled out + r := request(http.MethodGet, ae, "deflate") + ep := &profile.Profile{Accepted: providers.ParseAcceptEncoding("gzip;q=0.5, br")} + ep.Supported = providers.GZip + r = r.WithContext(profile.ToContext(r.Context(), ep)) + if got := list(r); !slices.Equal(got, []providers.Provider{providers.GZip}) { + t.Errorf("expected the response path's reading of the request, got %v", got) + } + if encodingLabel(providers.Identity) != identityLabel || encodingLabel(providers.GZip) != "gzip" { + t.Error("expected encodings to be labeled by name") + } +} + +func TestTeeBuffer(t *testing.T) { + tee := &teeBuffer{limit: 5} + for _, b := range []string{"ab", "cde"} { + if n, err := tee.Write([]byte(b)); n != len(b) || err != nil { + t.Fatalf("expected the write to be taken whole, got %d %v", n, err) + } + } + if string(tee.buf) != "abcde" || tee.overflow { + t.Errorf("expected writes within the limit to be collected, got %q", tee.buf) + } + // past the limit it stops collecting, without failing the response it shadows + if n, err := tee.Write([]byte("f")); n != 1 || err != nil || !tee.overflow || tee.buf != nil { + t.Errorf("expected an overflow to be absorbed, got %d %v", n, err) + } + if n, err := tee.Write([]byte("g")); n != 1 || err != nil || tee.buf != nil { + t.Error("expected writes after an overflow to be absorbed too") + } +} + +func TestErrorPageWriterImplicitHeader(t *testing.T) { + w := httptest.NewRecorder() + e := &errorPageWriter{ResponseWriter: w} + if _, err := e.Write([]byte("page")); err != nil { + t.Fatal(err) + } + if w.Code != http.StatusNotFound || e.Unwrap() != w { + t.Errorf("expected a write to imply a 404 on the wrapped writer, got %d", w.Code) + } + // a status other than the file's own 200 is not the error page's to change + w = httptest.NewRecorder() + (&errorPageWriter{ResponseWriter: w}).WriteHeader(http.StatusInternalServerError) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected another status to pass through, got %d", w.Code) + } +} + +type failingEncoder struct{} + +func (failingEncoder) Write([]byte) (int, error) { return 0, errors.New("failed") } +func (failingEncoder) Close() error { return nil } + +func TestEncoderFailureIsNotRemembered(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + s := newTestServer(t, testOptions(root)) + s.start() + orig := newEncoder + newEncoder = func(providers.Provider, io.Writer) io.WriteCloser { return failingEncoder{} } + get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + newEncoder = orig + if e := s.cache.held("big.css"); e == nil || e.unencodable.Load() != 0 || s.cache.files.Load() != 1 { + t.Error("expected a failure to release its reservation without marking the file") + } + resp := get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "gzip" || + decode(t, enc, body(t, resp)) != compressibleCSS || s.cache.get("big.css", providers.GZip) == nil { + t.Error("expected encoding to be tried again after a failure, and then held") + } +} + +func TestRenditionThatDoesNotShrinkIsNotHeld(t *testing.T) { + root := newTestSite(t) + // large enough to be tried, and random enough that encoding can only grow it + noise := make([]byte, 4096) + if _, err := rand.Read(noise); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "noise.txt"), string(noise)) + s := newTestServer(t, testOptions(root)) + s.start() + resp := get(t, s, http.MethodGet, "/noise.txt", headers.NameAcceptEncoding, "gzip") + if enc := resp.Header.Get(headers.NameContentEncoding); decode(t, enc, body(t, resp)) != string(noise) { + t.Error("expected the response under way to be completed as it began") + } + if e := s.cache.held("noise.txt"); e == nil || e.unencodable.Load() == 0 || s.cache.count() != 1 || + s.cache.size.Load() != entryCost("noise.txt", int64(len(noise))) { + t.Error("expected the file to be marked, and the rendition's reservation released") + } + resp = get(t, s, http.MethodGet, "/noise.txt", headers.NameAcceptEncoding, "gzip") + if resp.Header.Get(headers.NameContentEncoding) != "" || body(t, resp) != string(noise) { + t.Error("expected the file as stored from then on") + } +} + +func TestServingNeverWaitsOnTheCache(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + writeFile(t, filepath.Join(root, "held.css"), compressibleCSS) + s := newTestServer(t, testOptions(root)) + s.start() + get(t, s, http.MethodGet, "/held.css", headers.NameAcceptEncoding, "gzip") + if s.cache.count() != 2 { + t.Fatal("expected a file and its rendition to be held") + } + + // with the cache's lock held against them, requests of every kind must still complete + s.cache.mtx.Lock() + served := make(chan string, 1) + go func() { + defer close(served) + for _, test := range []struct{ target, accept string }{ + {"/held.css", "gzip"}, // a held rendition + {"/held.css", ""}, // a held file + {"/held.css", "br"}, // a held file, in a rendition that isn't + {"/big.css", "gzip"}, // nothing held, and a rendition wanted + {"/big.css", ""}, // nothing held + {"/nope.css", ""}, // nothing there + } { + r := httptest.NewRequest(http.MethodGet, "http://example.com"+test.target, nil) + if test.accept != "" { + r.Header.Set(headers.NameAcceptEncoding, test.accept) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + want := compressibleCSS + if test.target == "/nope.css" { + want = http.StatusText(http.StatusNotFound) + "\n" + } + if got := decode(t, w.Header().Get(headers.NameContentEncoding), w.Body.String()); got != want { + served <- test.target + " " + test.accept + return + } + } + }() + select { + case failed, bad := <-served: + if bad { + t.Errorf("%s: expected the complete response while the cache was locked", failed) + } + case <-time.After(10 * time.Second): + t.Error("a request waited on the cache's lock") + } + s.cache.mtx.Unlock() + awaitStores() + // what couldn't be reserved then is simply loaded by a later request + if s.cache.count() != 2 { + t.Errorf("expected nothing to have been held while the cache was locked, got %d", s.cache.count()) + } + get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + if s.cache.count() != 4 || s.cache.files.Load() != 4 { + t.Errorf("expected the file and its rendition to be held once the lock was free, got %d", s.cache.count()) + } +} + +func TestHeldRenditionIsNeverDecodedToStandInForTheFile(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + s := newTestServer(t, testOptions(root)) + s.start() + get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + // the file as stored is evicted, leaving only its gzip rendition held + s.cache.mtx.Lock() + s.cache.remove(s.cache.held("big.css")) + s.cache.mtx.Unlock() + if s.cache.count() != 1 || s.cache.get("big.css", providers.GZip) == nil { + t.Fatal("expected only the rendition to remain") + } + misses := responses(s, statusMiss, providers.Identity) + // asked for as stored, the file comes from disk rather than from decoding the rendition + resp := get(t, s, http.MethodGet, "/big.css") + if resp.Header.Get(headers.NameContentEncoding) != "" || body(t, resp) != compressibleCSS || + responses(s, statusMiss, providers.Identity) != misses+1 { + t.Error("expected the file to be read from disk") + } + // and asked for in an encoding that isn't held, both the file and that rendition are then held + s.cache.mtx.Lock() + s.cache.remove(s.cache.held("big.css")) + s.cache.mtx.Unlock() + resp = get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "br") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "br" || + decode(t, enc, body(t, resp)) != compressibleCSS || responses(s, statusMiss, providers.Brotli) != 1 { + t.Errorf("expected a rendition made from the file on disk, got %q", enc) + } + if s.cache.held("big.css") == nil || s.cache.get("big.css", providers.Brotli) == nil || s.cache.count() != 3 { + t.Errorf("expected the file and the new rendition to be held beside the old one, got %d", s.cache.count()) + } +} + +func TestNewRenditionIsStreamed(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + o := testOptions(root) + o.CacheControl = testCacheControl + s := newTestServer(t, o) + s.start() + // sent as it is encoded, so without a length; held, it is sent with its own + first := get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + second := get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + if first.Header.Get(headers.NameContentLength) != "" { + t.Error("expected a new rendition to be streamed, with no length known ahead of it") + } + sent, held := body(t, first), body(t, second) + if sent != held || second.Header.Get(headers.NameContentLength) != strconv.Itoa(len(held)) { + t.Error("expected the rendition that is held to be the bytes that were streamed") + } + for _, name := range []string{headers.NameETag, headers.NameLastModified, headers.NameVary, + headers.NameContentType, headers.NameCacheControl, headers.NameContentEncoding} { + if first.Header.Get(name) == "" || first.Header.Get(name) != second.Header.Get(name) { + t.Errorf("expected %s to be the same streamed and held, got %q and %q", + name, first.Header.Get(name), second.Header.Get(name)) + } + } + // held at its own size, rather than at the size of the buffer it was collected in + if v := s.cache.get("big.css", providers.GZip); v == nil || cap(v.body) != len(v.body) || + v.cost != entryCost(renditionKey("big.css", providers.GZip), int64(len(v.body))) { + t.Error("expected the rendition to be held and accounted for at its own size") + } +} + +func TestRenditionDoesNotCostTheFileItsPlace(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + o := testOptions(root) + o.FileserverCache.MaxFiles = 1 + s := newTestServer(t, o) + s.start() + h := eh.HandleCompression(s, s.compressible) + // with room for one object, the file is what is worth holding: every rendition is made from it + for range 4 { + resp := get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "gzip" || + decode(t, enc, body(t, resp)) != compressibleCSS { + t.Fatalf("expected an encoded response, got %q", enc) + } + if s.cache.held("big.css") == nil || s.cache.count() != 1 { + t.Fatal("expected the file to stay held, rather than be evicted for its own rendition") + } + } + // read from disk once, and encoded from memory after that + if responses(s, statusMiss, providers.GZip) != 1 || responses(s, statusPartialHit, providers.GZip) != 3 { + t.Errorf("expected one read from disk and three from memory, got %v and %v", + responses(s, statusMiss, providers.GZip), responses(s, statusPartialHit, providers.GZip)) + } + if testutil.ToFloat64(s.cache.metrics.evictions) != 0 { + t.Error("expected nothing to have been evicted") + } +} + +func TestConcurrentLoadsStartOneStore(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.start() + r, f, m := openForLoad(t, s, "app.css") + // a read in progress, with a burst of loads waiting to share it and its reservation + rsv := s.cache.reserve("app.css", providers.Identity, root, m.size, nil) + if rsv == nil { + t.Fatal("expected the file to be reserved") + } + shared := &entry{fileMeta: *m, key: "app.css", body: []byte(testCSS)} + release, leading := make(chan struct{}), make(chan struct{}) + go s.loads.Do("app.css", func() (any, error) { + close(leading) + <-release + return loaded{entry: shared, pending: rsv}, nil + }) + <-leading + const waiters = 64 + var wg sync.WaitGroup + for range waiters { + wg.Go(func() { + e, pending := s.load(r, f, m, "app.css") + s.storeEntry(e, pending) + }) + } + // let the waiters join the read before it completes + time.Sleep(50 * time.Millisecond) + before := s.started.Load() + close(release) + wg.Wait() + s.stores.Wait() + if started := s.started.Load() - before; started != 1 { + t.Errorf("expected %d loads that shared a read to start one store between them, got %d", waiters, started) + } + if s.cache.held("app.css") != shared || s.cache.files.Load() != 1 { + t.Error("expected the one store to have been made") + } +} + +func TestConcurrentColdRequestsStartFewStores(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.start() + const requests = 200 + var wg sync.WaitGroup + for range requests { + wg.Go(func() { + r := httptest.NewRequest(http.MethodGet, "http://example.com/app.css", nil) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != http.StatusOK || w.Body.String() != testCSS { + t.Errorf("unexpected response %d %q", w.Code, w.Body.String()) + } + }) + } + wg.Wait() + s.stores.Wait() + // a read that finishes before a later request arrives is followed by another, but each + // is one store however many requests shared it: nothing like one per request + if started := s.started.Load(); started < 1 || started > 8 { + t.Errorf("expected a handful of stores for %d requests, got %d", requests, started) + } + if s.cache.count() != 1 { + t.Errorf("expected the file to be held once, got %d", s.cache.count()) + } +} + +// inflatingEncoder writes more than it is given, as an encoding does to a file it is no use to +type inflatingEncoder struct{ w io.Writer } + +func (i inflatingEncoder) Write(b []byte) (int, error) { + if _, err := i.w.Write(append([]byte("inflated:"), b...)); err != nil { + return 0, err + } + return len(b), nil +} + +func (inflatingEncoder) Close() error { return nil } + +func TestUnhelpfulEncodingLeavesTheOthersToBeTried(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + s := newTestServer(t, testOptions(root)) + s.start() + h := eh.HandleCompression(s, s.compressible) + orig := newEncoder + defer func() { newEncoder = orig }() + newEncoder = func(enc providers.Provider, w io.Writer) io.WriteCloser { + if enc == providers.Deflate { + return inflatingEncoder{w} + } + return orig(enc, w) + } + // the first client to reach the file prefers the one encoding that doesn't shrink it + get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "deflate, br;q=0.5") + e := s.cache.held("big.css") + if e == nil || e.unhelpful() != providers.Deflate || + s.cache.get("big.css", providers.Deflate) != nil { + t.Fatal("expected only the encoding that failed to be marked, and not to be held") + } + // which costs the next client nothing: its own preference is still tried, and held + resp := get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "br") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "br" || + decode(t, enc, body(t, resp)) != compressibleCSS || s.cache.get("big.css", providers.Brotli) == nil { + t.Errorf("expected another encoding to still be tried and held, got %q", enc) + } + // a client that prefers the unhelpful one is given the next that it accepts + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "deflate, zstd;q=0.5") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "zstd" || + decode(t, enc, body(t, resp)) != compressibleCSS { + t.Errorf("expected the next most preferred encoding, got %q", enc) + } + // and one that accepts nothing else is sent the file as stored, by the response path too + for range 2 { + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "deflate") + if resp.Header.Get(headers.NameContentEncoding) != "" || body(t, resp) != compressibleCSS { + t.Error("expected the file as stored when only the unhelpful encoding is accepted") + } + } + // a conditional request is left to the response path, which passes the unhelpful one over too + resp = get(t, h, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "deflate, gzip;q=0.1", + "If-None-Match", `"other"`) + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "gzip" { + t.Errorf("expected the response path to pass over the unhelpful encoding, got %q", enc) + } + if e.unhelpful() != providers.Deflate { + t.Error("expected nothing else to have been marked") + } +} + +func TestConcurrentRenditionsShareOneStore(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.css"), compressibleCSS) + s := newTestServer(t, testOptions(root)) + s.start() + // as routed, behind the response path: a request that arrives between the shared read and + // its store takes the file from disk, and is encoded on its way out rather than as a rendition + h := eh.HandleCompression(s, s.compressible) + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + r := httptest.NewRequest(http.MethodGet, "http://example.com/big.css", nil) + r.Header.Set(headers.NameAcceptEncoding, "gzip") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if enc := w.Header().Get(headers.NameContentEncoding); enc != "gzip" { + t.Errorf("expected every response to be encoded, got %q", enc) + return + } + if got, err := gzip.Decode(w.Body.Bytes()); err != nil || string(got) != compressibleCSS { + t.Errorf("expected a complete rendition whether or not it was the one held: %v", err) + } + }) + } + wg.Wait() + awaitStores() + // a burst may leave the rendition to a later request, as a busy cache is never waited for + get(t, s, http.MethodGet, "/big.css", headers.NameAcceptEncoding, "gzip") + if s.cache.count() != 2 || s.cache.files.Load() != 2 { + t.Errorf("expected the file and one rendition to be held, got %d", s.cache.count()) + } +} + +func TestCodingGuardImplicitHeader(t *testing.T) { + w := httptest.NewRecorder() + g := &codingGuard{ResponseWriter: w} + if _, err := g.Write([]byte("ok")); err != nil { + t.Fatal(err) + } + if !g.wroteHeader || w.Code != http.StatusOK || g.Unwrap() != w { + t.Error("expected a write to imply a 200 header on the wrapped writer") + } +} + +func TestFileserverCacheAdmission(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "big.bin"), strings.Repeat("x", 64)) + writeFile(t, filepath.Join(root, "unknown"), strings.Repeat("y", 64)) + + o := testOptions(root) + o.FileserverCache.MaxFileSizeBytes = 32 + // room for index.html and its overhead, but not for a second file + o.FileserverCache.MaxSizeBytes = entryCost("index.html", int64(len(testHome))) + 10 + s := newTestServer(t, o) + s.start() + for range 2 { + for _, target := range []string{"/big.bin", "/unknown", "/", "/app.css"} { + if resp := get(t, s, http.MethodGet, target); resp.StatusCode != http.StatusOK { + t.Fatalf("%s: expected 200 got %d", target, resp.StatusCode) + } + } + } + // the large files exceed the per-file limit, and the other two take turns in the + // room there is for one: whichever was asked for last is the one that is held + if s.cache.count() != 1 || s.cache.held("app.css") == nil || s.cache.held("index.html") != nil { + t.Errorf("expected only app.css to be held, got %d entries", s.cache.count()) + } + if size := s.cache.size.Load(); size != entryCost("app.css", int64(len(testCSS))) { + t.Errorf("expected only app.css to be accounted for, got %d bytes", size) + } + if s.cache.files.Load() != 1 || s.watcher.Watched() != 1 { + t.Errorf("expected 1 file and 1 watch, got %d and %d", s.cache.files.Load(), s.watcher.Watched()) + } + if responses(s, statusDisk, providers.Identity) != 4 || responses(s, statusMiss, providers.Identity) != 4 { + t.Error("expected files too large to hold to be counted as sent from disk, and the rest as misses") + } + + o = testOptions(root) + o.FileserverCache.Disabled = true + s = newTestServer(t, o) + s.start() + if resp := get(t, s, http.MethodGet, "/"); resp.StatusCode != http.StatusOK || body(t, resp) != testHome { + t.Error("expected a server with no fileserver cache to serve from disk") + } + if s.cache != nil || s.watcher != nil { + t.Error("expected no cache or watcher when the fileserver cache is disabled") + } + s.stop() +} + +func TestInvalidationByEvent(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.start() + for _, target := range []string{"/", "/docs/", "/docs/guide.txt", "/app.css"} { + get(t, s, http.MethodGet, target) + } + if s.cache.count() != 4 { + t.Fatalf("expected 4 held files, got %d", s.cache.count()) + } + served := func(target, want string, status int) func() bool { + return func() bool { + resp := get(t, s, http.MethodGet, target) + return resp.StatusCode == status && (status != http.StatusOK || body(t, resp) == want) + } + } + replaceFile(t, filepath.Join(root, "app.css"), "body{color:red}") + if !waitFor(5*time.Second, served("/app.css", "body{color:red}", http.StatusOK)) { + t.Error("expected the replaced file to be served") + } + writeFile(t, filepath.Join(root, "index.html"), "rewritten in place") + if !waitFor(5*time.Second, served("/", "rewritten in place", http.StatusOK)) { + t.Error("expected the rewritten file to be served") + } + if err := os.Remove(filepath.Join(root, "docs", "guide.txt")); err != nil { + t.Fatal(err) + } + if !waitFor(5*time.Second, served("/docs/guide.txt", "", http.StatusNotFound)) { + t.Error("expected a removed file to be absent") + } + // a renamed directory takes the held files beneath it along + if err := os.Rename(filepath.Join(root, "docs"), filepath.Join(root, "manual")); err != nil { + t.Fatal(err) + } + if !waitFor(5*time.Second, served("/docs/", "", http.StatusNotFound)) { + t.Error("expected files under a renamed directory to be absent") + } + if !waitFor(5*time.Second, served("/manual/", testDocs, http.StatusOK)) { + t.Error("expected files under the new directory name to be served") + } + // an event from outside the root can't be mapped to a key, so everything goes + s.onFileEvent(filepath.Dir(root)) + if s.cache.count() != 0 { + t.Error("expected an unmappable event to purge the cache") + } + + s.stop() + if s.cache.count() != 0 || s.cache.held("index.html") != nil { + t.Error("expected a stopped server to hold nothing") + } + if s.cache.size.Load() != 0 || s.cache.files.Load() != 0 || s.watcher.Watched() != 0 { + t.Error("expected a stopped server to have released its capacity and watches") + } + if !served("/", "rewritten in place", http.StatusOK)() { + t.Error("expected a stopped server to keep serving from disk") + } +} + +func TestFileEventLeavesOtherLoadsAlone(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.cache.activate() + get(t, s, http.MethodGet, "/index.html") + // loads in progress, in the changed file's directory and in another one + sibling := s.cache.reserve("app.css", providers.Identity, root, int64(len(testCSS)), nil) + nested := s.cache.reserve("docs/guide.txt", providers.Identity, filepath.Join(root, "docs"), 5, nil) + if sibling == nil || nested == nil { + t.Fatal("expected both files to be reserved") + } + // an ordinary file changes, as one of many does during a deployment + s.onFileEvent(filepath.Join(root, "index.html")) + s.onFileEvent(filepath.Join(root, "favicon.ico")) + if s.cache.held("index.html") != nil { + t.Error("expected the changed file to be dropped") + } + if !sibling.commit(testEntry("app.css", testCSS)) || !nested.commit(testEntry("docs/guide.txt", "guide")) { + t.Error("expected loads of other files to be unaffected by the change") + } + // the directory changing is what takes the files beneath it along + pending := s.cache.reserve("docs/index.html", providers.Identity, filepath.Join(root, "docs"), + int64(len(testDocs)), nil) + s.onFileEvent(filepath.Join(root, "docs")) + if s.cache.held("docs/guide.txt") != nil || pending.commit(testEntry("docs/index.html", testDocs)) { + t.Error("expected a changed directory to take its held and loading files along") + } + if s.cache.held("app.css") == nil { + t.Error("expected a file outside the changed directory to stay held") + } +} + +func TestRevalidate(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + // active without a running watcher, so only revalidate can find changes + s.cache.activate() + for _, target := range []string{"/", "/app.css", "/docs/guide.txt"} { + get(t, s, http.MethodGet, target) + } + s.revalidate() + if s.cache.count() != 3 { + t.Fatalf("expected unchanged files to stay held, got %d", s.cache.count()) + } + replaceFile(t, filepath.Join(root, "app.css"), "body{color:red}") + if err := os.Remove(filepath.Join(root, "docs", "guide.txt")); err != nil { + t.Fatal(err) + } + if body(t, get(t, s, http.MethodGet, "/app.css")) != testCSS { + t.Fatal("expected the held file until revalidation") + } + s.revalidate() + if s.cache.count() != 1 || s.cache.held("index.html") == nil { + t.Errorf("expected only the unchanged file to stay held, got %d", s.cache.count()) + } + if body(t, get(t, s, http.MethodGet, "/app.css")) != "body{color:red}" { + t.Error("expected the replaced file after revalidation") + } +} + +func TestRevalidateReplacedRoot(t *testing.T) { + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(base, "v1", "index.html"), "v1") + writeFile(t, filepath.Join(base, "v2", "index.html"), "v2") + current := filepath.Join(base, "current") + if err := os.Symlink(filepath.Join(base, "v1"), current); err != nil { + t.Fatal(err) + } + s := newTestServer(t, testOptions(current)) + s.cache.activate() + if body(t, get(t, s, http.MethodGet, "/")) != "v1" { + t.Fatal("expected v1") + } + next := filepath.Join(base, "next") + if err := os.Symlink(filepath.Join(base, "v2"), next); err != nil { + t.Fatal(err) + } + if err := os.Rename(next, current); err != nil { + t.Fatal(err) + } + s.revalidate() + if body(t, get(t, s, http.MethodGet, "/")) != "v2" { + t.Error("expected the swapped-in root to be served after revalidation") + } + // a root that is briefly absent keeps the open one until it returns + if err := os.Remove(current); err != nil { + t.Fatal(err) + } + s.revalidate() + if body(t, get(t, s, http.MethodGet, "/")) != "v2" { + t.Error("expected the open root to keep serving while the path is absent") + } +} + +// loadEntry is load for a test with no use for the entry's store +func (s *server) loadEntry(root *os.Root, f *os.File, m *fileMeta, key string) *entry { + e, pending := s.load(root, f, m, key) + s.storeEntry(e, pending) + s.stores.Wait() + return e +} + +func openForLoad(t *testing.T, s *server, key string) (*os.Root, *os.File, *fileMeta) { + t.Helper() + root := s.root.Load().root + f, err := root.Open(key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { f.Close() }) + fi, err := f.Stat() + if err != nil { + t.Fatal(err) + } + m := s.newMeta(key, fi, "text/css") + return root, f, &m +} + +func TestLoadRejectsChangedFile(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.cache.activate() + r, f, m := openForLoad(t, s, "app.css") + replaceFile(t, filepath.Join(root, "app.css"), "body{color:red}") + if s.loadEntry(r, f, m, "app.css") != nil || s.cache.count() != 0 { + t.Error("expected a file replaced after it was opened not to be held") + } + if s.loadEntry(r, f, m, "missing.css") != nil { + t.Error("expected a file that is gone not to be held") + } + // a file that shrank after it was stat'd can't be read in full + r, f, m = openForLoad(t, s, "app.css") + m.size += 10 + if s.loadEntry(r, f, m, "app.css") != nil { + t.Error("expected a short read not to be held") + } + if s.cache.size.Load() != 0 || s.cache.files.Load() != 0 || s.watcher.Watched() != 0 { + t.Error("expected every failed load to release its reservation") + } +} + +func TestValidator(t *testing.T) { + root := newTestSite(t) + path := filepath.Join(root, "app.css") + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + want := `"` + strconv.FormatInt(fi.ModTime().UnixNano(), 16) + "-" + strconv.FormatInt(fi.Size(), 16) + `"` + disk := newTestServer(t, testOptions(root)) + held := newTestServer(t, testOptions(root)) + held.start() + // the validator is the modification time and size alone, however the file is served + for range 2 { + for name, s := range map[string]*server{"disk": disk, "memory": held} { + for _, method := range []string{http.MethodHead, http.MethodGet} { + if got := get(t, s, method, "/app.css").Header.Get(headers.NameETag); got != want { + t.Errorf("%s %s: expected validator %s got %s", name, method, want, got) + } + } + } + } + m := disk.newMeta("app.css", fi, "text/css") + if m.etag != want || m.weakETag != "W/"+want { + t.Errorf("expected %s and its weak form, got %s and %s", want, m.etag, m.weakETag) + } + + later := fi.ModTime().Add(time.Second) + if err = os.Chtimes(path, later, later); err != nil { + t.Fatal(err) + } + touched := get(t, disk, http.MethodGet, "/app.css").Header.Get(headers.NameETag) + if touched == want { + t.Error("expected a new validator for a new modification time") + } + writeFile(t, path, testCSS+" ") + if err = os.Chtimes(path, later, later); err != nil { + t.Fatal(err) + } + if got := get(t, disk, http.MethodGet, "/app.css").Header.Get(headers.NameETag); got == touched { + t.Error("expected a new validator for a new size") + } +} + +func TestBodyIsReadOnlyWhenNeeded(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.start() + resp := get(t, s, http.MethodHead, "/app.css") + etag, modified := resp.Header.Get(headers.NameETag), resp.Header.Get(headers.NameLastModified) + if resp.StatusCode != http.StatusOK || etag == "" || + resp.Header.Get(headers.NameContentLength) != strconv.Itoa(len(testCSS)) { + t.Fatalf("expected a complete HEAD response, got %d %v", resp.StatusCode, resp.Header) + } + requests := []struct { + name string + status int + hdrs []string + }{ + {"matching etag", http.StatusNotModified, []string{"If-None-Match", etag}}, + {"unmodified since", http.StatusNotModified, []string{"If-Modified-Since", modified}}, + {"failed precondition", http.StatusPreconditionFailed, []string{"If-Match", `"other"`}}, + {"range", http.StatusPartialContent, []string{"Range", "bytes=0-2"}}, + {"unsatisfiable range", http.StatusRequestedRangeNotSatisfiable, []string{"Range", "bytes=99-"}}, + } + for _, test := range requests { + if resp = get(t, s, http.MethodGet, "/app.css", test.hdrs...); resp.StatusCode != test.status { + t.Errorf("%s: expected %d got %d", test.name, test.status, resp.StatusCode) + } + get(t, s, http.MethodHead, "/app.css", test.hdrs...) + } + if s.cache.count() != 0 || s.cache.files.Load() != 0 || s.watcher.Watched() != 0 { + t.Fatalf("expected requests that need no body, or only part of one, to hold nothing; got %d", + s.cache.count()) + } + // the validator a HEAD returned still matches once a GET has loaded the file + resp = get(t, s, http.MethodGet, "/app.css") + if body(t, resp) != testCSS || resp.Header.Get(headers.NameETag) != etag || s.cache.count() != 1 { + t.Error("expected a full GET to load the file under the same validator") + } +} + +func TestConcurrentLoadsShareOneRead(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.cache.activate() + r, f, m := openForLoad(t, s, "app.css") + shared := &entry{fileMeta: *m, key: "app.css", body: []byte(testCSS)} + + // hold the file's flight open, as a load in progress does + release := make(chan struct{}) + leading := make(chan struct{}) + go s.loads.Do("app.css", func() (any, error) { + close(leading) + <-release + return loaded{entry: shared}, nil + }) + <-leading + const followers = 16 + results := make(chan *entry, followers) + for range followers { + go func() { + e, _ := s.load(r, f, m, "app.css") + results <- e + }() + } + // followers may only wait: none reserves capacity or allocates a body of its own + time.Sleep(50 * time.Millisecond) + if s.cache.files.Load() != 0 || s.cache.size.Load() != 0 { + t.Errorf("expected waiting loads to reserve nothing, got %d files", s.cache.files.Load()) + } + close(release) + for range followers { + if e := <-results; e != shared { + t.Fatal("expected every waiting load to share the one that was in progress") + } + } + // a shared entry describing a different file than the request opened is refused + _, _, other := openForLoad(t, s, "index.html") + go s.loads.Do("app.css", func() (any, error) { return loaded{entry: &entry{fileMeta: *other}}, nil }) + if !waitFor(5*time.Second, func() bool { return s.loadEntry(r, f, m, "app.css") != nil }) { + t.Error("expected a load to succeed once no mismatched flight is in progress") + } +} + +func TestConcurrentColdMisses(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + s.start() + var wg sync.WaitGroup + for range 32 { + wg.Go(func() { + r := httptest.NewRequest(http.MethodGet, "http://example.com/app.css", nil) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != http.StatusOK || w.Body.String() != testCSS { + t.Errorf("unexpected response %d %q", w.Code, w.Body.String()) + } + }) + } + wg.Wait() + // the store is made away from the requests, which were made without the helper that waits for it + s.stores.Wait() + if s.cache.count() != 1 || s.cache.files.Load() != 1 || + s.cache.size.Load() != entryCost("app.css", int64(len(testCSS))) { + t.Errorf("expected one entry and no leaked reservations, got %d files %d bytes", + s.cache.files.Load(), s.cache.size.Load()) + } +} + +func TestLazyContent(t *testing.T) { + root := newTestSite(t) + s := newTestServer(t, testOptions(root)) + r, f, m := openForLoad(t, s, "app.css") + // the cache is inactive, so the body can't be held and is read from the file + l := &lazyContent{s: s, root: r, f: f, meta: m, key: "app.css"} + if n, err := l.Seek(0, io.SeekEnd); err != nil || n != int64(len(testCSS)) { + t.Errorf("expected the size from metadata, got %d %v", n, err) + } + if _, err := l.Seek(-1, io.SeekStart); err == nil { + t.Error("expected an error seeking before the start") + } + if _, err := l.Seek(2, io.SeekStart); err != nil { + t.Fatal(err) + } + if n, err := l.Seek(1, io.SeekCurrent); err != nil || n != 3 { + t.Errorf("expected offset 3, got %d %v", n, err) + } + b, err := io.ReadAll(l) + if err != nil || string(b) != testCSS[3:] { + t.Errorf("expected %q from the file, got %q %v", testCSS[3:], b, err) + } + if _, err = l.Seek(1, io.SeekStart); err != nil { + t.Fatal(err) + } + if b, _ = io.ReadAll(l); string(b) != testCSS[1:] { + t.Errorf("expected %q after seeking the file, got %q", testCSS[1:], b) + } + + // held, it is read from memory, from wherever the reader was positioned + s.cache.activate() + r, f, m = openForLoad(t, s, "app.css") + l = &lazyContent{s: s, root: r, f: f, meta: m, key: "app.css"} + if _, err = l.Seek(4, io.SeekStart); err != nil { + t.Fatal(err) + } + b, _ = io.ReadAll(l) + s.stores.Wait() + if string(b) != testCSS[4:] || s.cache.count() != 1 { + t.Errorf("expected %q from memory, got %q", testCSS[4:], b) + } + if n, err := l.Read(make([]byte, 1)); n != 0 || err != io.EOF { + t.Errorf("expected EOF at the end of the body, got %d %v", n, err) + } + // a closed file fails the fallback seek rather than serving from the wrong offset + r, f, m = openForLoad(t, s, "index.html") + s.cache.retire() + f.Close() + l = &lazyContent{s: s, root: r, f: f, meta: m, key: "index.html"} + if _, err = l.Read(make([]byte, 1)); err == nil { + t.Error("expected an error reading a closed file") + } +} + +func TestConcurrentServeAndChange(t *testing.T) { + root := newTestSite(t) + o := testOptions(root) + o.FileserverCache.RevalidationInterval = 1000 * 1000 // 1ms + s := newTestServer(t, o) + s.start() + valid := map[string]bool{testCSS: true} + versions := make([]string, 20) + for i := range versions { + versions[i] = strings.Repeat("v", i+1) + valid[versions[i]] = true + } + var wg sync.WaitGroup + stop := make(chan struct{}) + for range 8 { + wg.Go(func() { + for { + select { + case <-stop: + return + default: + } + r := httptest.NewRequest(http.MethodGet, "http://example.com/app.css", nil) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != http.StatusOK || !valid[w.Body.String()] { + t.Errorf("unexpected response %d %q", w.Code, w.Body.String()) + return + } + } + }) + } + for _, v := range versions { + replaceFile(t, filepath.Join(root, "app.css"), v) + time.Sleep(2 * time.Millisecond) + } + close(stop) + wg.Wait() + last := versions[len(versions)-1] + if !waitFor(5*time.Second, func() bool { + return body(t, get(t, s, http.MethodGet, "/app.css")) == last + }) { + t.Error("expected the final version to be served once changes settle") + } +} + +func TestWellKnownIsServed(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, ".well-known", "security.txt"), "Contact: mailto:security@example.com") + writeFile(t, filepath.Join(root, ".well-known", "acme-challenge", "token"), "token.thumbprint") + writeFile(t, filepath.Join(root, ".well-known", ".secret"), "secret") + writeFile(t, filepath.Join(root, "docs", ".well-known", "security.txt"), "nested") + s := newTestServer(t, testOptions(root)) + s.start() + tests := []struct { + target, body string + status int + }{ + {"/.well-known/security.txt", "Contact: mailto:security@example.com", http.StatusOK}, + {"/.well-known/acme-challenge/token", "token.thumbprint", http.StatusOK}, + {"/.well-known", "", http.StatusMovedPermanently}, + {"/.well-known/", "", http.StatusNotFound}, + {"/.well-known/.secret", "", http.StatusNotFound}, + {"/docs/.well-known/security.txt", "", http.StatusNotFound}, + {"/.env", "", http.StatusNotFound}, + } + for range 2 { + for _, test := range tests { + resp := get(t, s, http.MethodGet, test.target) + if resp.StatusCode != test.status || (test.status == http.StatusOK && body(t, resp) != test.body) { + t.Errorf("%s: expected %d got %d", test.target, test.status, resp.StatusCode) + } + } + } + // the exemption is for serving it, not for advertising it + if err := os.Remove(filepath.Join(root, "index.html")); err != nil { + t.Fatal(err) + } + s.cache.purge() + page := body(t, get(t, withDirectoryListing(s, s), http.MethodGet, "/")) + if !strings.Contains(page, "docs/") || strings.Contains(page, ".well-known") { + t.Errorf("expected a listing without the well-known directory:\n%s", page) + } +} + +func TestNotFoundFile(t *testing.T) { + root := newTestSite(t) + const page = "

nothing here

" + writeFile(t, filepath.Join(root, "errors", "404.html"), page) + missing := []string{"/nope", "/nope/", "/empty/", "/.env", "/docs/.hidden", "/app.css/", "/a/b/c.js"} + + o := testOptions(root) + o.NotFoundFile = "/errors/404.html" + if err := o.Initialize(); err != nil { + t.Fatal(err) + } + if o.NotFoundFile != "errors/404.html" || o.NotFoundStatus != http.StatusNotFound { + t.Fatalf("expected a normalized file and a default status, got %q %d", o.NotFoundFile, o.NotFoundStatus) + } + s := newTestServer(t, o) + s.start() + for range 2 { + for _, target := range missing { + // whatever the request asked of the missing file is no business of the error page's + resp := get(t, s, http.MethodGet, target, "Range", "bytes=0-3", "If-None-Match", "*") + if resp.StatusCode != http.StatusNotFound || body(t, resp) != page { + t.Errorf("%s: expected the error page with a 404, got %d", target, resp.StatusCode) + } + h := resp.Header + if h.Get(headers.NameContentType) != contentTypeHTML || h.Get(headers.NameCacheControl) != headers.ValueNoCache || + h.Get(headers.NameETag) != "" || h.Get(headers.NameLastModified) != "" || h.Get(headers.NameAcceptRanges) != "" { + t.Errorf("%s: expected an error page that can't be revalidated or reused, got %v", target, h) + } + } + } + if resp := get(t, s, http.MethodHead, "/nope"); resp.StatusCode != http.StatusNotFound || body(t, resp) != "" || + resp.Header.Get(headers.NameContentLength) != strconv.Itoa(len(page)) { + t.Errorf("expected a HEAD to describe the error page, got %d %v", resp.StatusCode, resp.Header) + } + // what exists is unaffected, including the error page when asked for by its own path + if resp := get(t, s, http.MethodGet, "/app.css"); resp.StatusCode != http.StatusOK || body(t, resp) != testCSS { + t.Errorf("expected an existing file to be served, got %d", resp.StatusCode) + } + if resp := get(t, s, http.MethodGet, "/errors/404.html"); resp.StatusCode != http.StatusOK || + resp.Header.Get(headers.NameETag) == "" { + t.Errorf("expected the error page to be an ordinary file at its own path, got %d", resp.StatusCode) + } + if resp := get(t, s, http.MethodGet, "/docs"); resp.StatusCode != http.StatusMovedPermanently { + t.Errorf("expected a directory to still redirect, got %d", resp.StatusCode) + } + if resp := get(t, s, http.MethodPost, "/nope"); resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected a refused method to stay refused, got %d", resp.StatusCode) + } + // a listing still takes precedence for a directory that can be listed + if resp := get(t, withDirectoryListing(s, s), http.MethodGet, "/empty/"); resp.StatusCode != http.StatusOK || + !strings.Contains(body(t, resp), "a.txt") { + t.Errorf("expected a listing in place of the error page, got %d", resp.StatusCode) + } + + // without the file, or with a directory in its place, a plain 404 rather than a loop + for _, file := range []string{"errors/missing.html", "docs"} { + o = testOptions(root) + o.NotFoundFile, o.NotFoundStatus = file, http.StatusOK + s = newTestServer(t, o) + resp := get(t, s, http.MethodGet, "/nope") + if resp.StatusCode != http.StatusNotFound || !strings.Contains(body(t, resp), http.StatusText(http.StatusNotFound)) { + t.Errorf("%s: expected a plain 404, got %d", file, resp.StatusCode) + } + } +} + +func TestNotFoundFileForSinglePageApplication(t *testing.T) { + root := newTestSite(t) + writeFile(t, filepath.Join(root, "index.html"), ""+strings.Repeat("", 100)+"") + app, err := os.ReadFile(filepath.Join(root, "index.html")) + if err != nil { + t.Fatal(err) + } + o := testOptions(root) + o.NotFoundFile, o.NotFoundStatus = "index.html", http.StatusOK + o.CacheControl = testCacheControl + s := newTestServer(t, o) + s.start() + h := eh.HandleCompression(s, s.compressible) + // every route of the application is the application, served as the file it is + etag := get(t, h, http.MethodGet, "/").Header.Get(headers.NameETag) + for _, target := range []string{"/dashboard", "/users/42/edit", "/settings/"} { + resp := get(t, h, http.MethodGet, target) + if resp.StatusCode != http.StatusOK || body(t, resp) != string(app) || resp.Header.Get(headers.NameETag) != etag { + t.Errorf("%s: expected the application with a 200, got %d", target, resp.StatusCode) + } + if resp.Header.Get(headers.NameCacheControl) != testCacheControl { + t.Errorf("%s: expected the file's own Cache-Control", target) + } + if resp = get(t, h, http.MethodGet, target, "If-None-Match", etag); resp.StatusCode != http.StatusNotModified { + t.Errorf("%s: expected the application to revalidate, got %d", target, resp.StatusCode) + } + resp = get(t, h, http.MethodGet, target, headers.NameAcceptEncoding, "gzip") + if enc := resp.Header.Get(headers.NameContentEncoding); enc != "gzip" || decode(t, enc, body(t, resp)) != string(app) { + t.Errorf("%s: expected the application's held rendition, got %q", target, enc) + } + } + if s.cache.count() != 2 { + t.Errorf("expected one held file and one rendition to serve every route, got %d", s.cache.count()) + } + if resp := get(t, h, http.MethodGet, "/app.css"); body(t, resp) != testCSS { + t.Error("expected an existing file to be served in place of the application") + } +} + +func TestEvictionKeepsServing(t *testing.T) { + root := newTestSite(t) + for i := range 20 { + writeFile(t, filepath.Join(root, "pages", strconv.Itoa(i), "page.txt"), "page "+strconv.Itoa(i)) + } + o := testOptions(root) + o.FileserverCache.MaxFiles = 5 + s := newTestServer(t, o) + s.start() + for range 3 { + for i := range 20 { + // the home page is read between every page, so it is never the least recently used + if body(t, get(t, s, http.MethodGet, "/")) != testHome { + t.Fatal("expected the home page") + } + target := "/pages/" + strconv.Itoa(i) + "/page.txt" + if got := body(t, get(t, s, http.MethodGet, target)); got != "page "+strconv.Itoa(i) { + t.Fatalf("%s: got %q", target, got) + } + } + } + if s.cache.count() != 5 || s.cache.files.Load() != 5 || s.watcher.Watched() > 5 { + t.Errorf("expected the limit to hold, got %d held over %d watches", s.cache.count(), s.watcher.Watched()) + } + if s.cache.held("index.html") == nil { + t.Error("expected the file in constant use to have stayed held") + } + if responses(s, statusHit, providers.Identity) < 59 { + t.Errorf("expected the home page to be a hit every time after the first, got %v", + responses(s, statusHit, providers.Identity)) + } + if testutil.ToFloat64(s.cache.metrics.evictions) == 0 { + t.Error("expected evictions to be counted") + } +} + +func TestFailLogsUnexpectedErrors(t *testing.T) { + s := newTestServer(t, testOptions(newTestSite(t))) + w := httptest.NewRecorder() + s.fail(w, httptest.NewRequest(http.MethodGet, "http://example.com/app.css", nil), "app.css", + os.ErrPermission, false) + if w.Code != http.StatusNotFound { + t.Errorf("expected an unreadable file to look absent, got %d", w.Code) + } + if !bytes.Contains(w.Body.Bytes(), []byte(http.StatusText(http.StatusNotFound))) { + t.Error("expected the status text as the error body") + } +} diff --git a/pkg/backends/static/server_unix_test.go b/pkg/backends/static/server_unix_test.go new file mode 100644 index 000000000..30e592a67 --- /dev/null +++ b/pkg/backends/static/server_unix_test.go @@ -0,0 +1,45 @@ +//go:build unix + +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package static + +import ( + "net/http" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestServeRefusesPipe(t *testing.T) { + root := newTestSite(t) + if err := syscall.Mkfifo(filepath.Join(root, "pipe"), 0o600); err != nil { + t.Skipf("unable to create a named pipe: %v", err) + } + s := newTestServer(t, testOptions(root)) + done := make(chan int, 1) + go func() { done <- get(t, s, http.MethodGet, "/pipe").StatusCode }() + select { + case status := <-done: + if status != http.StatusNotFound { + t.Errorf("expected a named pipe to look absent, got %d", status) + } + case <-time.After(5 * time.Second): + t.Fatal("request for a named pipe blocked") + } +} diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 14d5a7c64..dd40590db 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -28,6 +28,7 @@ import ( "github.com/trickstercache/trickster/v2/pkg/appinfo" "github.com/trickstercache/trickster/v2/pkg/appinfo/usage" + "github.com/trickstercache/trickster/v2/pkg/backends/static" "github.com/trickstercache/trickster/v2/pkg/config" "github.com/trickstercache/trickster/v2/pkg/config/reload" "github.com/trickstercache/trickster/v2/pkg/config/validate" @@ -265,6 +266,7 @@ func Reload(si *instance.ServerInstance, source string, args ...string) (bool, e si.Backends = oldClients si.Caches = oldCaches si.HealthChecker = oldHealthChecker + static.StartClients(oldClients) metrics.ReloadFailuresTotal.Inc() metrics.LastReloadSuccessful.Set(0) metrics.ReloadDurationSeconds.Observe(time.Since(startTime).Seconds()) diff --git a/pkg/daemon/setup/setup.go b/pkg/daemon/setup/setup.go index cf1d76df2..fe7ecd0a5 100644 --- a/pkg/daemon/setup/setup.go +++ b/pkg/daemon/setup/setup.go @@ -31,6 +31,7 @@ import ( "github.com/trickstercache/trickster/v2/pkg/backends" "github.com/trickstercache/trickster/v2/pkg/backends/alb" "github.com/trickstercache/trickster/v2/pkg/backends/healthcheck" + "github.com/trickstercache/trickster/v2/pkg/backends/static" "github.com/trickstercache/trickster/v2/pkg/cache" "github.com/trickstercache/trickster/v2/pkg/cache/index" "github.com/trickstercache/trickster/v2/pkg/cache/manager" @@ -161,6 +162,7 @@ func Shutdown(si *instance.ServerInstance) { stopDiscovery(si) if si.Backends != nil { alb.StopPools(si.Backends) + static.StopClients(si.Backends) } if si.HealthChecker != nil { si.HealthChecker.Shutdown() @@ -263,6 +265,7 @@ func ApplyConfig(si *instance.ServerInstance, newConf *config.Config, if si.Backends != nil { alb.StopPools(si.Backends) + static.StopClients(si.Backends) } if si.HealthChecker != nil { si.HealthChecker.Shutdown() @@ -289,6 +292,7 @@ func ApplyConfig(si *instance.ServerInstance, newConf *config.Config, logging.Pairs{keys.Detail: err.Error()}, errorFunc) return err } + static.StartClients(clients) routing.RegisterDefaultBackendRoutesForListeners(listenerRouters, newConf, clients, tracers) routing.RegisterHealthHandler(mr, newConf.MgmtConfig.HealthHandlerPath, si.HealthChecker, clients) applyListenerConfigs(newConf, si.Config, listenerRouters, rh, mr, tracers, clients, errorFunc, lg, diff --git a/pkg/daemon/setup/shutdown_test.go b/pkg/daemon/setup/shutdown_test.go index c0f2a3a6c..03b53a556 100644 --- a/pkg/daemon/setup/shutdown_test.go +++ b/pkg/daemon/setup/shutdown_test.go @@ -20,13 +20,19 @@ import ( "net/http" "net/http/httptest" "net/url" + "runtime" "sync/atomic" "testing" "time" + "github.com/trickstercache/trickster/v2/pkg/backends" ao "github.com/trickstercache/trickster/v2/pkg/backends/alb/options" "github.com/trickstercache/trickster/v2/pkg/backends/healthcheck" ho "github.com/trickstercache/trickster/v2/pkg/backends/healthcheck/options" + bo "github.com/trickstercache/trickster/v2/pkg/backends/options" + "github.com/trickstercache/trickster/v2/pkg/backends/providers" + "github.com/trickstercache/trickster/v2/pkg/backends/static" + so "github.com/trickstercache/trickster/v2/pkg/backends/static/options" "github.com/trickstercache/trickster/v2/pkg/daemon/instance" do "github.com/trickstercache/trickster/v2/pkg/discovery/options" "github.com/trickstercache/trickster/v2/pkg/parsing/timeconv" @@ -63,6 +69,22 @@ func TestShutdownStopsHealthChecks(t *testing.T) { require.Equal(t, stopped, hits.Load(), "the target was probed after Shutdown") } +func TestShutdownStopsStaticClients(t *testing.T) { + o := bo.New() + o.Provider = providers.Static + o.Static = so.New() + o.Static.Root = t.TempDir() + client, err := static.NewClient("site", o, nil, nil, nil, nil) + require.NoError(t, err) + clients := backends.Backends{"site": client} + static.StartClients(clients) + + before := runtime.NumGoroutine() + Shutdown(&instance.ServerInstance{Backends: clients}) + require.Eventually(t, func() bool { return runtime.NumGoroutine() < before }, + 5*time.Second, 5*time.Millisecond, "the static client's watcher kept running after Shutdown") +} + func TestShutdownStopsDiscovery(t *testing.T) { si, c, clients := newDiscoveryFixture(t, unavailableDiscoverer(), &do.Query{Service: "svc"}, ao.StartupPolicyRetry) diff --git a/pkg/encoding/handler/handler.go b/pkg/encoding/handler/handler.go index 2a6637d90..bf5665041 100644 --- a/pkg/encoding/handler/handler.go +++ b/pkg/encoding/handler/handler.go @@ -49,9 +49,10 @@ func HandleCompression(next http.Handler, compressTypes sets.Set[string]) http.H Level: -1, } - // this checks the Client's accept-encoding header to identify any compatible encodings - enc := r.Header.Get(headers.NameAcceptEncoding) - ep.SupportedHeaderVal, ep.Supported = providers.GetCompatibleWebProviders(enc) + // this checks the Client's accept-encoding header to identify any compatible encodings, + // in the client's order of preference + ep.Accepted = providers.ParseAcceptEncoding(r.Header[headers.NameAcceptEncoding]...) + ep.SupportedHeaderVal, ep.Supported = ep.Accepted.String(), ep.Accepted.Bitmap() r = r.WithContext(profile.ToContext(r.Context(), ep)) diff --git a/pkg/encoding/handler/handler_test.go b/pkg/encoding/handler/handler_test.go index 8349a57f4..bfaaf44e5 100644 --- a/pkg/encoding/handler/handler_test.go +++ b/pkg/encoding/handler/handler_test.go @@ -19,9 +19,11 @@ package handler import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/trickstercache/trickster/v2/pkg/appinfo" + "github.com/trickstercache/trickster/v2/pkg/encoding/profile" "github.com/trickstercache/trickster/v2/pkg/proxy/headers" "github.com/trickstercache/trickster/v2/pkg/util/sets" ) @@ -59,3 +61,47 @@ func TestHandleCompressionSupportsHijacker(t *testing.T) { t.Error("compression response writer does not implement http.Hijacker") } } + +func TestHandleCompressionHonorsWeights(t *testing.T) { + var upstream string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // what an origin would be asked for, were this request proxied to one + if ep := profile.FromContext(r.Context()); ep != nil { + upstream = ep.SupportedHeaderVal + } + w.Header().Set(headers.NameContentType, headers.ValueTextPlain) + w.Write([]byte(strings.Repeat("trickster ", 100))) + }) + h := HandleCompression(next, sets.New([]string{headers.ValueTextPlain})) + tests := []struct { + accept, encoding, upstream string + }{ + // unweighted, Trickster's own preference applies + {"gzip, deflate, br, zstd", "zstd", "zstd, br, gzip, deflate"}, + {"gzip;q=1.0, zstd;q=0.5", "gzip", "gzip, zstd;q=0.5"}, + {"zstd;q=0, BR;q=0.3, deflate;q=0.4", "deflate", "deflate;q=0.4, br;q=0.3"}, + {"gzip;q=0, identity", "", ""}, + {"*", "zstd", "zstd, br, gzip, deflate"}, + {"gzip;q=0, *;q=0.5", "zstd", "zstd;q=0.5, br;q=0.5, deflate;q=0.5"}, + {"identity;q=1, gzip;q=0.5", "", ""}, + {"identity;q=0, deflate;q=0.1", "deflate", "deflate;q=0.1"}, + // nothing acceptable is left, and identity is sent regardless, as other servers do + {"identity;q=0", "", ""}, + {"", "", ""}, + } + for _, test := range tests { + upstream = "" + r := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + if test.accept != "" { + r.Header.Set(headers.NameAcceptEncoding, test.accept) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if got := w.Header().Get(headers.NameContentEncoding); got != test.encoding { + t.Errorf("Accept-Encoding %q: expected %q got %q", test.accept, test.encoding, got) + } + if upstream != test.upstream { + t.Errorf("Accept-Encoding %q: expected upstream %q got %q", test.accept, test.upstream, upstream) + } + } +} diff --git a/pkg/encoding/profile/profile.go b/pkg/encoding/profile/profile.go index 4aac27b4b..01854a26c 100644 --- a/pkg/encoding/profile/profile.go +++ b/pkg/encoding/profile/profile.go @@ -32,6 +32,9 @@ type Profile struct { // Supported is the Client-Accepted Encodings filtered against Trickster supported Encodings // represented as a bitmap Supported providers.Provider + // Accepted is the Supported encodings in the Client's order of preference. When empty, + // Trickster's own order of preference is used. + Accepted providers.Accepted // SupportedHeaderVal is the Accept-Encoding value representation of the Supported byte // that is used when proxying to an origin SupportedHeaderVal string @@ -127,6 +130,13 @@ func (p *Profile) GetEncoderInitializer() (providers.EncoderInitializer, string) return nil, "" } + // the client's most preferred encoding that is still supported, as Supported may have + // been narrowed since the header was read + for i := range p.Accepted.Len() { + if enc := p.Accepted.At(i); p.Supported&enc != 0 { + return providers.SelectEncoderInitializer(enc) + } + } return providers.SelectEncoderInitializer(p.Supported) } diff --git a/pkg/encoding/profile/profile_test.go b/pkg/encoding/profile/profile_test.go index 9b9a4d607..347570aa4 100644 --- a/pkg/encoding/profile/profile_test.go +++ b/pkg/encoding/profile/profile_test.go @@ -20,6 +20,7 @@ import ( "strings" "testing" + "github.com/trickstercache/trickster/v2/pkg/encoding/providers" "github.com/trickstercache/trickster/v2/pkg/proxy/headers" "github.com/trickstercache/trickster/v2/pkg/util/sets" ) @@ -151,3 +152,29 @@ func TestGetDecoderInitializer(t *testing.T) { t.Error("expected nil function") } } + +func TestGetEncoderInitializerHonorsClientPreference(t *testing.T) { + p := &Profile{ + ContentType: "text/plain", + CompressTypes: sets.New([]string{"text/plain"}), + Accepted: providers.ParseAcceptEncoding("zstd;q=0.2, gzip;q=0.9, br;q=0.5"), + } + p.Supported = p.Accepted.Bitmap() + if _, name := p.GetEncoderInitializer(); name != providers.GZipValue { + t.Errorf("expected the client's most preferred encoding, got %q", name) + } + // narrowed since the header was read, the next most preferred that remains + p.Supported &^= providers.GZip + if _, name := p.GetEncoderInitializer(); name != providers.BrotliValue { + t.Errorf("expected the most preferred encoding still supported, got %q", name) + } + p.Supported = 0 + if ei, _ := p.GetEncoderInitializer(); ei != nil { + t.Error("expected no encoder when nothing is supported") + } + // a profile built without a client's header keeps Trickster's own preference + p.Accepted, p.Supported = providers.Accepted{}, providers.GZip|providers.Brotli + if _, name := p.GetEncoderInitializer(); name != providers.BrotliValue { + t.Errorf("expected Trickster's preference without a client's, got %q", name) + } +} diff --git a/pkg/encoding/providers/accept.go b/pkg/encoding/providers/accept.go new file mode 100644 index 000000000..3f89dc342 --- /dev/null +++ b/pkg/encoding/providers/accept.go @@ -0,0 +1,206 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package providers + +import ( + "math" + "math/bits" + "strconv" + "strings" +) + +const ( + // maxWeight is a weight of 1, in the thousandths that a qvalue has the precision for + maxWeight = 1000 + // numWebProviders is the number of encodings a client can be sent + numWebProviders = 4 +) + +// Accepted is the supported encodings a client would rather be sent than identity, most preferred +// first: by its weights, then by Trickster's preference among equals. As a value, it allocates nothing. +type Accepted struct { + list [numWebProviders]Provider + weights [numWebProviders]uint16 + n int +} + +const ( + identityValue = "identity" + wildcardValue = "*" + // unweighted marks identity or the wildcard as absent from the header + unweighted = -1 +) + +// ParseAcceptEncoding returns what the values of an Accept-Encoding header accept. A coding +// takes the weight of its most specific match: its own entry, or else the wildcard's. One the +// client weights below identity is left out, as identity is always there to be sent instead. +func ParseAcceptEncoding(values ...string) Accepted { + var named [numWebProviders]int + for i := range named { + named[i] = unweighted + } + identity, wildcard := unweighted, unweighted + for _, value := range values { + for token := range strings.SplitSeq(value, ",") { + name, params, _ := strings.Cut(token, ";") + // codings are case-insensitive; lowering one that is already lowercase copies nothing + name = strings.ToLower(strings.TrimSpace(name)) + switch enc, ok := webProviderLookup[name]; { + case ok: + // the first of a coding's entries is the one that counts + if i := providerIndex(enc); named[i] == unweighted { + named[i] = int(parseWeight(params)) + } + case name == identityValue && identity == unweighted: + identity = int(parseWeight(params)) + case name == wildcardValue && wildcard == unweighted: + wildcard = int(parseWeight(params)) + } + } + } + // identity not named takes the wildcard's weight, and with neither it is acceptable but + // least preferred: a client that lists codings is asking for them + if identity == unweighted { + identity = max(wildcard, 0) + } + var a Accepted + for i, weight := range named { + if weight == unweighted { + weight = wildcard + } + if weight > 0 && weight >= identity { + a.insert(Provider(1)< 0 && (a.weights[i-1] < weight || (a.weights[i-1] == weight && a.list[i-1] > enc)) { + a.list[i], a.weights[i] = a.list[i-1], a.weights[i-1] + i-- + } + a.list[i], a.weights[i] = enc, weight + a.n++ +} + +// Len returns the number of accepted encodings +func (a Accepted) Len() int { + return a.n +} + +// At returns the i'th most preferred encoding +func (a Accepted) At(i int) Provider { + return a.list[i] +} + +// Preferred returns the most preferred encoding, or Identity when none is accepted +func (a Accepted) Preferred() Provider { + if a.n == 0 { + return Identity + } + return a.list[0] +} + +// Bitmap returns the accepted encodings as a bitmap +func (a Accepted) Bitmap() Provider { + var b Provider + for _, enc := range a.list[:a.n] { + b |= enc + } + return b +} + +// Filter returns the accepted encodings that are also in the bitmap, in the same order +func (a Accepted) Filter(bitmap Provider) Accepted { + var out Accepted + for i, enc := range a.list[:a.n] { + if bitmap&enc != 0 { + out.list[out.n], out.weights[out.n] = enc, a.weights[i] + out.n++ + } + } + return out +} + +// String returns an Accept-Encoding header value that asks for what the client did +// of what Trickster supports, keeping the client's weights where it gave any +func (a Accepted) String() string { + weighted := false + for _, weight := range a.weights[:a.n] { + weighted = weighted || weight != maxWeight + } + if !weighted { + // the preference order of equals is the order of the bitmap, whose values are prebuilt + return bitmapHeaderValues[a.Bitmap()] + } + var sb strings.Builder + for i, enc := range a.list[:a.n] { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(providerValLookup[enc]) + if weight := a.weights[i]; weight != maxWeight { + sb.WriteString(";q=0.") + digits := strconv.Itoa(int(weight) + maxWeight)[1:] + sb.WriteString(strings.TrimRight(digits, "0")) + } + } + return sb.String() +} + +// bitmapHeaderValues holds the header value of every combination of web providers +var bitmapHeaderValues [maxWebProvider << 1]string + +func init() { + for b := range Provider(len(bitmapHeaderValues)) { + names := make([]string, 0, numWebProviders) + for p := Provider(1); p <= maxWebProvider; p <<= 1 { + if b&p != 0 { + names = append(names, providerValLookup[p]) + } + } + bitmapHeaderValues[b] = strings.Join(names, ", ") + } +} diff --git a/pkg/encoding/providers/accept_test.go b/pkg/encoding/providers/accept_test.go new file mode 100644 index 000000000..38917b6ec --- /dev/null +++ b/pkg/encoding/providers/accept_test.go @@ -0,0 +1,155 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package providers + +import ( + "slices" + "testing" +) + +func acceptedList(a Accepted) []Provider { + out := make([]Provider, 0, a.Len()) + for i := range a.Len() { + out = append(out, a.At(i)) + } + return out +} + +func TestParseAcceptEncoding(t *testing.T) { + tests := []struct { + name string + values []string + expected []Provider + header string + }{ + {"none", nil, nil, ""}, + {"empty", []string{""}, nil, ""}, + {"unsupported", []string{"compress, identity, x-unknown"}, nil, ""}, + // without weights the client has no preference, so Trickster's own applies + {"unweighted", []string{"gzip, deflate, br, zstd"}, + []Provider{Zstandard, Brotli, GZip, Deflate}, "zstd, br, gzip, deflate"}, + {"unweighted subset", []string{"deflate, gzip"}, []Provider{GZip, Deflate}, "gzip, deflate"}, + {"weighted", []string{"gzip;q=1.0, zstd;q=0.5, br;q=0.8"}, + []Provider{GZip, Brotli, Zstandard}, "gzip, br;q=0.8, zstd;q=0.5"}, + {"weights and defaults", []string{"br;q=0.9, gzip, zstd;q=0.9"}, + []Provider{GZip, Zstandard, Brotli}, "gzip, zstd;q=0.9, br;q=0.9"}, + {"all weighted alike", []string{"gzip;q=0.5, zstd;q=0.5"}, + []Provider{Zstandard, GZip}, "zstd;q=0.5, gzip;q=0.5"}, + {"refused", []string{"gzip;q=0, br;q=0.0, zstd;q=0.000, deflate"}, []Provider{Deflate}, "deflate"}, + // the wildcard stands for every coding that isn't named, at its own weight + {"wildcard only", []string{"*"}, []Provider{Zstandard, Brotli, GZip, Deflate}, "zstd, br, gzip, deflate"}, + {"weighted wildcard", []string{"*;q=0.5"}, []Provider{Zstandard, Brotli, GZip, Deflate}, + "zstd;q=0.5, br;q=0.5, gzip;q=0.5, deflate;q=0.5"}, + {"refused with a wildcard", []string{"gzip;q=0, *;q=1"}, + []Provider{Zstandard, Brotli, Deflate}, "zstd, br, deflate"}, + {"named over the wildcard", []string{"*;q=0.5, gzip"}, + []Provider{GZip, Zstandard, Brotli, Deflate}, "gzip, zstd;q=0.5, br;q=0.5, deflate;q=0.5"}, + {"named under the wildcard", []string{"gzip;q=0.2, *;q=0.8"}, + []Provider{Zstandard, Brotli, Deflate}, "zstd;q=0.8, br;q=0.8, deflate;q=0.8"}, + {"refused wildcard", []string{"*;q=0, BR"}, []Provider{Brotli}, "br"}, + {"repeated wildcard", []string{"*;q=0, *, gzip"}, []Provider{GZip}, "gzip"}, + // identity is always there to be sent, so a coding weighted below it never is + {"identity preferred", []string{"identity;q=1, gzip;q=0.5"}, nil, ""}, + {"identity preferred to some", []string{"identity;q=0.3, gzip;q=1.0, deflate;q=0.2"}, []Provider{GZip}, "gzip"}, + {"identity weighted alike", []string{"identity;q=0.5, gzip;q=0.5"}, []Provider{GZip}, "gzip;q=0.5"}, + {"identity by the wildcard", []string{"gzip;q=0.4, *;q=0.6, br;q=0.6"}, + []Provider{Zstandard, Brotli, Deflate}, "zstd;q=0.6, br;q=0.6, deflate;q=0.6"}, + {"identity refused", []string{"identity;q=0, gzip;q=0.1"}, []Provider{GZip}, "gzip;q=0.1"}, + {"identity refused by the wildcard", []string{"*;q=0, deflate;q=0.1"}, []Provider{Deflate}, "deflate;q=0.1"}, + {"identity named over the wildcard", []string{"*;q=0, identity, gzip;q=0.5"}, nil, ""}, + // with nothing left that is acceptable, identity is what is sent, as other servers do + {"everything refused", []string{"identity;q=0, gzip;q=0, *;q=0"}, nil, ""}, + {"precision", []string{"gzip;q=0.001, br;q=0.125"}, []Provider{Brotli, GZip}, "br;q=0.125, gzip;q=0.001"}, + {"rounds to refused", []string{"gzip;q=0.0001, br"}, []Provider{Brotli}, "br"}, + {"case and spacing", []string{" GZIP ; Q = 0.5 ,BR"}, []Provider{Brotli, GZip}, "br, gzip;q=0.5"}, + {"other parameters", []string{"gzip;level=9;q=0.5;x=y, br;level=1"}, + []Provider{Brotli, GZip}, "br, gzip;q=0.5"}, + // a coding that was named is not refused for a weight that can't be read + {"malformed weights", []string{"gzip;q=high, br;q=-1, zstd;q=NaN, deflate;q"}, + []Provider{Zstandard, Brotli, GZip, Deflate}, "zstd, br, gzip, deflate"}, + {"over weight", []string{"gzip;q=7, br;q=0.5"}, []Provider{GZip, Brotli}, "gzip, br;q=0.5"}, + {"repeated", []string{"gzip;q=0.5, gzip;q=1, br;q=0.8"}, []Provider{Brotli, GZip}, "br;q=0.8, gzip;q=0.5"}, + {"repeated after refusal", []string{"gzip;q=0, gzip"}, nil, ""}, + {"alias", []string{"x-gzip"}, []Provider{GZip}, "gzip"}, + {"lines", []string{"deflate;q=0.2", "zstd, br;q=0.4"}, + []Provider{Zstandard, Brotli, Deflate}, "zstd, br;q=0.4, deflate;q=0.2"}, + {"empty members", []string{",, gzip,,"}, []Provider{GZip}, "gzip"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a := ParseAcceptEncoding(test.values...) + if got := acceptedList(a); !slices.Equal(got, test.expected) { + t.Errorf("expected %v got %v", test.expected, got) + } + if a.String() != test.header { + t.Errorf("expected header %q got %q", test.header, a.String()) + } + var bitmap Provider + for _, enc := range test.expected { + bitmap |= enc + } + if a.Bitmap() != bitmap { + t.Errorf("expected bitmap %d got %d", bitmap, a.Bitmap()) + } + preferred := Identity + if len(test.expected) > 0 { + preferred = test.expected[0] + } + if a.Preferred() != preferred { + t.Errorf("expected %s to be preferred, got %s", preferred, a.Preferred()) + } + // what the header value asks for is what was accepted + if again := ParseAcceptEncoding(a.String()); !slices.Equal(acceptedList(again), test.expected) { + t.Errorf("expected %q to parse back to %v, got %v", a.String(), test.expected, acceptedList(again)) + } + }) + } +} + +func TestAcceptedFilter(t *testing.T) { + a := ParseAcceptEncoding("gzip, zstd;q=0.5, br;q=0.8") + f := a.Filter(Zstandard | GZip) + if got := acceptedList(f); !slices.Equal(got, []Provider{GZip, Zstandard}) || f.String() != "gzip, zstd;q=0.5" { + t.Errorf("expected the order and weights to be kept, got %v %q", got, f.String()) + } + if a.Filter(Identity).Len() != 0 || a.Filter(Deflate).Preferred() != Identity { + t.Error("expected nothing to pass an empty bitmap") + } +} + +func TestGetCompatibleWebProvidersWeighted(t *testing.T) { + s, p := GetCompatibleWebProviders("gzip;q=0.8, zstd;q=0, BR") + if s != "br, gzip;q=0.8" || p != Brotli|GZip { + t.Errorf("expected weighted and refused codings to be honored, got %q %d", s, p) + } +} + +func BenchmarkParseAcceptEncoding(b *testing.B) { + for name, value := range map[string]string{ + "browser": "gzip, deflate, br, zstd", + "weighted": "gzip;q=1.0, br;q=0.8, zstd;q=0.5", + } { + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + a := ParseAcceptEncoding(value) + _ = a.Bitmap() + _ = a.Preferred() + } + }) + } +} diff --git a/pkg/encoding/providers/providers.go b/pkg/encoding/providers/providers.go index d963bff4b..73582c9f2 100644 --- a/pkg/encoding/providers/providers.go +++ b/pkg/encoding/providers/providers.go @@ -95,6 +95,7 @@ func init() { providerLookup[s] = p } AllSupportedWebProviders = strings.Join(webProviders, ", ") + webProviderLookup[GZipAltValue] = GZip providerLookup[BrotliAltValue] = Brotli providerLookup[ZstandardAltValue] = Zstandard } @@ -122,32 +123,8 @@ func Providers() []string { // negotiated between Trickster and the Client. The string representation is compatible with // the Accept-Encoding header func GetCompatibleWebProviders(acceptedEncodings string) (string, Provider) { - var b Provider - var s string - // if an empty acceptedEncodings is provided, exit asap - if acceptedEncodings == s { - return s, b - } - // this converts the acceptedEncodings string into a bitmap of Trickster-compatible encoders - for enc := range strings.SplitSeq(acceptedEncodings, ",") { - if v, ok := webProviderLookup[strings.TrimSpace(enc)]; ok { - b |= v - } - } - // if there were no compatible encoders accepted, exit asap - if b == 0 { - return s, b - } - comp := make([]string, len(providerValLookup)) - var k int - // otherwise, this builds the list of compatible encoders from the bitmap - for i := Provider(1); i <= maxWebProvider; i <<= 1 { - if b&i == i { - comp[k] = providerValLookup[i] - k++ - } - } - return strings.Join(comp[:k], ", "), b + a := ParseAcceptEncoding(acceptedEncodings) + return a.String(), a.Bitmap() } // Clone returns a perfect copy of the lookup diff --git a/pkg/observability/keys/keys.go b/pkg/observability/keys/keys.go index 30cdc7ba1..a0f91551a 100644 --- a/pkg/observability/keys/keys.go +++ b/pkg/observability/keys/keys.go @@ -103,6 +103,8 @@ const ( Variant = "variant" Worker = "worker" + Encoding = "encoding" + // For Metrics Labels that use Underscores ALB_Name = "alb_name" Backend_Name = "backend_name" diff --git a/pkg/observability/metrics/metrics.go b/pkg/observability/metrics/metrics.go index a457a64df..1c930ee19 100644 --- a/pkg/observability/metrics/metrics.go +++ b/pkg/observability/metrics/metrics.go @@ -28,20 +28,21 @@ import ( ) const ( - metricNamespace = "trickster" - cacheSubsystem = "cache" - proxySubsystem = providers.Proxy - configSubsystem = "config" - buildSubsystem = "build" - frontendSubsystem = "frontend" - albSubsystem = "alb" - healthSubsystem = "healthcheck" - sqlSubsystem = "sql" - mysqlSubsystem = "mysql" - graphiteSubsystem = providers.Graphite - druidSubsystem = providers.Druid - tlsSubsystem = "tls" - accessLogSubsystem = "accesslog" + metricNamespace = "trickster" + cacheSubsystem = "cache" + proxySubsystem = providers.Proxy + configSubsystem = "config" + buildSubsystem = "build" + frontendSubsystem = "frontend" + albSubsystem = "alb" + healthSubsystem = "healthcheck" + sqlSubsystem = "sql" + mysqlSubsystem = "mysql" + graphiteSubsystem = providers.Graphite + druidSubsystem = providers.Druid + tlsSubsystem = "tls" + accessLogSubsystem = "accesslog" + fileserverSubsystem = "fileserver" ) // Default histogram buckets used by trickster @@ -357,6 +358,73 @@ var ( []string{keys.Cache_Name, keys.Provider}, ) + // FileserverResponses is a Counter of files served by static backends, by how the + // Fileserver cache was used and the encoding of the rendition the file server produced + FileserverResponses = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: metricNamespace, + Subsystem: fileserverSubsystem, + Name: "responses_total", + Help: "Count of files served by a static backend, by Fileserver cache status and encoding.", + }, + []string{keys.Backend_Name, keys.Cache_Status, keys.Encoding}, + ) + + // FileserverCacheEvents is a Counter of events that remove objects from a Fileserver cache + FileserverCacheEvents = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: metricNamespace, + Subsystem: fileserverSubsystem, + Name: "cache_events_total", + Help: "Count of objects removed from a static backend's Fileserver cache, by event.", + }, + []string{keys.Backend_Name, keys.Event}, + ) + + // FileserverCacheObjects is a Gauge of the objects in a Fileserver cache, including loads in progress + FileserverCacheObjects = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: fileserverSubsystem, + Name: "cache_usage_objects", + Help: "Number of objects in a static backend's Fileserver cache.", + }, + []string{keys.Backend_Name}, + ) + + // FileserverCacheBytes is a Gauge of the accounted bytes in a Fileserver cache, including loads in progress + FileserverCacheBytes = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: fileserverSubsystem, + Name: "cache_usage_bytes", + Help: "Number of accounted bytes in a static backend's Fileserver cache.", + }, + []string{keys.Backend_Name}, + ) + + // FileserverCacheMaxObjects is a Gauge of the most objects a Fileserver cache will hold + FileserverCacheMaxObjects = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: fileserverSubsystem, + Name: "cache_max_usage_objects", + Help: "Maximum number of objects in a static backend's Fileserver cache before eviction.", + }, + []string{keys.Backend_Name}, + ) + + // FileserverCacheMaxBytes is a Gauge of the most accounted bytes a Fileserver cache will hold + FileserverCacheMaxBytes = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: fileserverSubsystem, + Name: "cache_max_usage_bytes", + Help: "Maximum number of accounted bytes in a static backend's Fileserver cache before eviction.", + }, + []string{keys.Backend_Name}, + ) + // ProxyMaxConnections is a Gauge representing the max number of active concurrent connections in the server ProxyMaxConnections = prometheus.NewGauge( prometheus.GaugeOpts{ @@ -950,6 +1018,12 @@ func init() { prometheus.MustRegister(CacheBytes) prometheus.MustRegister(CacheMaxObjects) prometheus.MustRegister(CacheMaxBytes) + prometheus.MustRegister(FileserverResponses) + prometheus.MustRegister(FileserverCacheEvents) + prometheus.MustRegister(FileserverCacheObjects) + prometheus.MustRegister(FileserverCacheBytes) + prometheus.MustRegister(FileserverCacheMaxObjects) + prometheus.MustRegister(FileserverCacheMaxBytes) prometheus.MustRegister(BuildInfo) prometheus.MustRegister(LastReloadSuccessful) prometheus.MustRegister(LastReloadSuccessfulTimestamp) diff --git a/pkg/proxy/handlers/handlers.go b/pkg/proxy/handlers/handlers.go index 82a98613f..0c3da76ca 100644 --- a/pkg/proxy/handlers/handlers.go +++ b/pkg/proxy/handlers/handlers.go @@ -28,11 +28,13 @@ const ( NameLocalResponse = "localresponse" // NameRedirect answers with a redirection composed from the request NameRedirect = "redirect" + // NameStatic serves files from a local directory + NameStatic = "static" ) // IsLocal reports whether a handler answers from configuration alone. A // request such a handler matches has no upstream to be tunneled to, so an // upgrade request is answered by the handler rather than diverted to one. func IsLocal(name string) bool { - return name == NameLocalResponse || name == NameRedirect + return name == NameLocalResponse || name == NameRedirect || name == NameStatic } diff --git a/pkg/proxy/handlers/handlers_test.go b/pkg/proxy/handlers/handlers_test.go index eff7f56b4..5540283ec 100644 --- a/pkg/proxy/handlers/handlers_test.go +++ b/pkg/proxy/handlers/handlers_test.go @@ -19,7 +19,7 @@ package handlers import "testing" func TestIsLocal(t *testing.T) { - for _, name := range []string{NameLocalResponse, NameRedirect} { + for _, name := range []string{NameLocalResponse, NameRedirect, NameStatic} { if !IsLocal(name) { t.Errorf("%q answers locally", name) } diff --git a/pkg/proxy/headers/headers.go b/pkg/proxy/headers/headers.go index 87478e2d7..4f516eaed 100644 --- a/pkg/proxy/headers/headers.go +++ b/pkg/proxy/headers/headers.go @@ -141,6 +141,8 @@ const ( NameIfNoneMatch = "If-None-Match" // NameIfMatch represents the HTTP Header Name of "If-Match" NameIfMatch = "If-Match" + // NameAcceptRanges represents the HTTP Header Name of "Accept-Ranges" + NameAcceptRanges = "Accept-Ranges" // NameIfRange represents the HTTP Header Name of "If-Range" NameIfRange = "If-Range" // NameCDNCacheControl represents the HTTP Header Name of "CDN-Cache-Control", diff --git a/pkg/watchers/filesystem/dirs.go b/pkg/watchers/filesystem/dirs.go new file mode 100644 index 000000000..1598ae89c --- /dev/null +++ b/pkg/watchers/filesystem/dirs.go @@ -0,0 +1,378 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filesystem + +import ( + "errors" + "maps" + "runtime" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/trickstercache/trickster/v2/pkg/observability/keys" + "github.com/trickstercache/trickster/v2/pkg/observability/logging" + "github.com/trickstercache/trickster/v2/pkg/observability/logging/logger" + "github.com/trickstercache/trickster/v2/pkg/util/safego" + "github.com/trickstercache/trickster/v2/pkg/watchers" + + "github.com/fsnotify/fsnotify" +) + +// DirOptions configures a DirWatcher +type DirOptions struct { + // Name identifies the DirWatcher in log events + Name string + // Interval is the backstop cadence for OnInterval; it must be > 0 + Interval time.Duration + // OnEvent is called with the path of each entry that changed in a watched + // directory. It runs on the watch goroutine and must not block. + OnEvent func(path string) + // OnLost is optionally called when change events may have been dropped + OnLost func() + // OnInterval is optionally called at Start and on every Interval, so the + // consumer can find changes that produced no event + OnInterval func() +} + +// ErrNoEventHandler is returned by NewDirWatcher when OnEvent is nil +var ErrNoEventHandler = errors.New("filesystem watcher: no event handler") + +// DirWatcher is a restartable watchers.Watcher reporting per-entry changes in +// a set of directories that grows while it runs. It never reads file content. +type DirWatcher struct { + opts DirOptions + + // mtx guards the watcher's state, and is never held across a call to the platform, so that + // nothing that only changes what is wanted ever waits on one. platform serializes those calls. + mtx sync.RWMutex + platform sync.Mutex + // arming counts the callers of Watch waiting for platform, which disarming gives way to + arming atomic.Int32 + // dirs is the directories that are wanted, and live the ones that are armed, which differ + // while a platform call is pending. live only changes with platform held, so its holder + // knows exactly what is armed, whatever Watch and Unwatch have been asked for meanwhile. + dirs map[string]struct{} + live map[string]struct{} + // stale is the directories that are still armed but no longer wanted. They are disarmed by + // the watch goroutine, so that Unwatch never makes its caller wait on the platform. + stale map[string]struct{} + wake chan struct{} + running bool + done chan struct{} + stopped chan struct{} + // events is nil when stopped or when fsnotify is unavailable + events *fsnotify.Watcher +} + +var _ watchers.Watcher = &DirWatcher{} + +// NewDirWatcher returns a DirWatcher for DirOptions without starting it +func NewDirWatcher(o *DirOptions) (*DirWatcher, error) { + if o == nil { + return nil, ErrNilOptions + } + if o.OnEvent == nil { + return nil, ErrNoEventHandler + } + if o.Interval <= 0 { + return nil, ErrInvalidInterval + } + return &DirWatcher{ + opts: *o, dirs: make(map[string]struct{}), live: make(map[string]struct{}), + stale: make(map[string]struct{}), wake: make(chan struct{}, 1), + }, nil +} + +// Watch adds dir to the watched set. It is safe for concurrent use and may be +// called while stopped; the directory is then armed by the next Start. +func (w *DirWatcher) Watch(dir string) { + w.mtx.RLock() + _, wanted := w.dirs[dir] + _, live := w.live[dir] + w.mtx.RUnlock() + if wanted && live { + return + } + w.mtx.Lock() + w.dirs[dir] = struct{}{} + // armed already, it was merely unwanted until now, and is kept at no cost to the platform + if _, live = w.live[dir]; live { + delete(w.stale, dir) + w.mtx.Unlock() + return + } + w.mtx.Unlock() + // wanted but not armed: by this call, or by another that is still waiting to arm it, which + // this one then waits with, so that the directory is armed whenever Watch returns + w.arming.Add(1) + w.platform.Lock() + w.arming.Add(-1) + defer w.platform.Unlock() + w.arm(dir) +} + +// arm requires platform. It arms dir if it is still wanted and not yet armed, and reports +// false if the platform refused it, which is left to OnInterval. What was wanted when the +// caller began may not be by now, and arming it regardless would leave it armed for good. +func (w *DirWatcher) arm(dir string) bool { + w.mtx.RLock() + _, wanted := w.dirs[dir] + _, live := w.live[dir] + events := w.events + w.mtx.RUnlock() + if !wanted || live || events == nil { + return true + } + if err := events.Add(dir); err != nil { + logger.Debug("unable to event-watch directory", + logging.Pairs{keys.Name: w.opts.Name, "dir": dir, keys.Detail: err.Error()}) + return false + } + w.mtx.Lock() + defer w.mtx.Unlock() + w.live[dir] = struct{}{} + // unwanted while it was being armed, when it wasn't yet armed for Unwatch to mark as stale + if _, wanted = w.dirs[dir]; !wanted { + w.markStale(dir) + } + return true +} + +// markStale requires mtx. The watch goroutine is told once, however many directories go +// stale before it looks. +func (w *DirWatcher) markStale(dir string) { + w.stale[dir] = struct{}{} + select { + case w.wake <- struct{}{}: + default: + } +} + +// Unwatch removes dir from the watched set. It is safe for concurrent use, and never waits on +// the platform: the directory is disarmed soon after by the watch goroutine, and until then +// events for it may still be delivered. +func (w *DirWatcher) Unwatch(dir string) { + w.mtx.Lock() + defer w.mtx.Unlock() + if _, ok := w.dirs[dir]; !ok { + return + } + delete(w.dirs, dir) + // one that isn't armed has nothing to disarm: a Watch still waiting to arm it will find it + // unwanted and leave it be, and one that is arming it now marks it stale once it has + if _, live := w.live[dir]; live { + w.markStale(dir) + } +} + +// disarm releases the directories that are no longer wanted, one at a time, so that a Watch +// that has to arm a directory waits for one of them at most. An Unwatch waits for none. +func (w *DirWatcher) disarm() { + for w.disarmOne() { + // a mutex lets the goroutine that just released it take it again ahead of one that is + // waiting, so this one stands aside for as long as a request is waiting to arm a directory + for w.arming.Load() > 0 { + runtime.Gosched() + } + } +} + +func (w *DirWatcher) disarmOne() bool { + // held before a directory is chosen, so that one chosen is disarmed before it can be armed again + w.platform.Lock() + defer w.platform.Unlock() + w.mtx.Lock() + var dir string + var found bool + for dir = range w.stale { + delete(w.stale, dir) + delete(w.live, dir) + found = true + break + } + events := w.events + w.mtx.Unlock() + if found && events != nil { + // an error means the platform already dropped the watch with the directory + _ = events.Remove(dir) + } + return found +} + +// Watched returns the number of directories in the watched set +func (w *DirWatcher) Watched() int { + w.mtx.RLock() + defer w.mtx.RUnlock() + return len(w.dirs) +} + +// Start begins or resumes watching. OnInterval runs synchronously before +// Start returns. No-op if already running. +func (w *DirWatcher) Start() { + w.mtx.Lock() + if w.running { + w.mtx.Unlock() + return + } + prevStopped := w.stopped + w.running = true + done, stopped := make(chan struct{}), make(chan struct{}) + w.done, w.stopped = done, stopped + w.mtx.Unlock() + if prevStopped != nil { + // wait for previous cycle's goroutine before overlapping a restart + <-prevStopped + } + events := w.startEventWatches() + if w.opts.OnInterval != nil { + w.opts.OnInterval() + } + safego.Go(func(r any, stack []byte) { + logger.Error("filesystem directory watcher goroutine panic", logging.Pairs{ + keys.Name: w.opts.Name, "panic": r, "stack": string(stack), + }) + }, func() { w.run(events, done, stopped) }) +} + +// Close stops the DirWatcher and waits for its goroutine to exit. No-op if stopped. +func (w *DirWatcher) Close() { + w.mtx.Lock() + if !w.running { + w.mtx.Unlock() + return + } + w.running = false + done, stopped := w.done, w.stopped + w.mtx.Unlock() + close(done) + <-stopped +} + +// startEventWatches best-effort arms fsnotify on the watched set; failure is interval-only. +func (w *DirWatcher) startEventWatches() *fsnotify.Watcher { + ew, err := newEventWatcher() + if err != nil { + logger.Debug("fsnotify unavailable; filesystem directory watcher is interval-only", + logging.Pairs{keys.Name: w.opts.Name, keys.Detail: err.Error()}) + return nil + } + w.platform.Lock() + defer w.platform.Unlock() + w.mtx.Lock() + w.events = ew + dirs := slices.Collect(maps.Keys(w.dirs)) + w.mtx.Unlock() + for _, dir := range dirs { + w.arm(dir) + } + return ew +} + +// newEventWatcher is replaced in tests to simulate a platform without fsnotify +var newEventWatcher = fsnotify.NewWatcher + +// rearm re-adds dropped directory watches and forgets directories that are +// gone, so a later Watch of a recreated directory arms it again. +func (w *DirWatcher) rearm() { + w.platform.Lock() + defer w.platform.Unlock() + w.mtx.Lock() + events := w.events + if events == nil { + w.mtx.Unlock() + return + } + watched := events.WatchList() + slices.Sort(watched) + // a directory that was removed took its watch with it, which the platform doesn't report + for dir := range w.live { + if _, ok := slices.BinarySearch(watched, dir); !ok { + delete(w.live, dir) + delete(w.stale, dir) + } + } + dirs := slices.Collect(maps.Keys(w.dirs)) + w.mtx.Unlock() + for _, dir := range dirs { + if !w.arm(dir) { + w.mtx.Lock() + delete(w.dirs, dir) + w.mtx.Unlock() + } + } +} + +func (w *DirWatcher) run(events *fsnotify.Watcher, done, stopped chan struct{}) { + defer close(stopped) + ticker := time.NewTicker(w.opts.Interval) + defer ticker.Stop() + var eventC chan fsnotify.Event + var errC chan error + if events != nil { + defer func() { + // with platform held, so that nothing is armed on an event source as it closes + w.platform.Lock() + defer w.platform.Unlock() + w.mtx.Lock() + w.events = nil + // closing the event source disarms everything it had armed + clear(w.stale) + clear(w.live) + w.mtx.Unlock() + events.Close() + }() + eventC, errC = events.Events, events.Errors + } + for { + select { + case <-done: + return + case <-w.wake: + w.disarm() + case <-ticker.C: + if w.opts.OnInterval != nil { + w.opts.OnInterval() + } + w.disarm() + w.rearm() + case ev, ok := <-eventC: + if !ok { + eventC = nil + continue + } + w.opts.OnEvent(ev.Name) + case err, ok := <-errC: + if !ok { + errC = nil + continue + } + w.handleError(err) + } + } +} + +func (w *DirWatcher) handleError(err error) { + if errors.Is(err, fsnotify.ErrEventOverflow) && w.opts.OnLost != nil { + w.opts.OnLost() + return + } + logger.Debug("filesystem directory watcher event error", logging.Pairs{ + keys.Name: w.opts.Name, keys.Detail: err.Error(), + }) +} diff --git a/pkg/watchers/filesystem/dirs_test.go b/pkg/watchers/filesystem/dirs_test.go new file mode 100644 index 000000000..bfdc1f55c --- /dev/null +++ b/pkg/watchers/filesystem/dirs_test.go @@ -0,0 +1,599 @@ +/* + * Copyright 2018 The Trickster Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filesystem + +import ( + "errors" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/fsnotify/fsnotify" +) + +type eventRecorder struct { + mtx sync.Mutex + paths map[string]int + lost atomic.Int64 + intervals atomic.Int64 +} + +func newEventRecorder() *eventRecorder { + return &eventRecorder{paths: make(map[string]int)} +} + +func (r *eventRecorder) onEvent(path string) { + r.mtx.Lock() + defer r.mtx.Unlock() + r.paths[path]++ +} + +func (r *eventRecorder) seen(path string) bool { + r.mtx.Lock() + defer r.mtx.Unlock() + return r.paths[path] > 0 +} + +func (r *eventRecorder) options(interval time.Duration) *DirOptions { + return &DirOptions{ + Name: "test", + Interval: interval, + OnEvent: r.onEvent, + OnLost: func() { r.lost.Add(1) }, + OnInterval: func() { r.intervals.Add(1) }, + } +} + +func TestNewDirWatcherErrors(t *testing.T) { + r := newEventRecorder() + if _, err := NewDirWatcher(nil); !errors.Is(err, ErrNilOptions) { + t.Errorf("expected ErrNilOptions, got %v", err) + } + if _, err := NewDirWatcher(&DirOptions{Interval: time.Second}); !errors.Is(err, ErrNoEventHandler) { + t.Errorf("expected ErrNoEventHandler, got %v", err) + } + if _, err := NewDirWatcher(&DirOptions{OnEvent: r.onEvent}); !errors.Is(err, ErrInvalidInterval) { + t.Errorf("expected ErrInvalidInterval, got %v", err) + } +} + +func TestDirWatcherReportsChangedEntries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a.txt") + if err := os.WriteFile(path, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + r := newEventRecorder() + w, err := NewDirWatcher(r.options(time.Hour)) + if err != nil { + t.Fatal(err) + } + w.Start() + defer w.Close() + w.Start() // no-op while running + if r.intervals.Load() != 1 { + t.Errorf("expected one synchronous interval call at Start, got %d", r.intervals.Load()) + } + w.Watch(dir) + w.Watch(dir) // already watched + if err := os.WriteFile(path, []byte("2"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(path) }) { + t.Fatal("expected an event for the modified file") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + created := filepath.Join(dir, "b.txt") + if err := os.WriteFile(created, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(created) }) { + t.Fatal("expected an event for the created file") + } +} + +func TestDirWatcherArmsDirsWatchedWhileStopped(t *testing.T) { + dir := t.TempDir() + r := newEventRecorder() + w, err := NewDirWatcher(r.options(time.Hour)) + if err != nil { + t.Fatal(err) + } + w.Close() // no-op while stopped + w.Watch(dir) + w.Watch(filepath.Join(dir, "missing")) // can't be armed; must not fail Start + w.Start() + path := filepath.Join(dir, "a.txt") + if err := os.WriteFile(path, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(path) }) { + t.Fatal("expected an event in a directory watched before Start") + } + w.Close() + + // a restart re-arms the set and reports the interval synchronously again + before := r.intervals.Load() + w.Start() + defer w.Close() + if r.intervals.Load() != before+1 { + t.Error("expected a synchronous interval call on restart") + } + path2 := filepath.Join(dir, "b.txt") + if err := os.WriteFile(path2, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(path2) }) { + t.Fatal("expected an event after restart") + } +} + +func TestDirWatcherIntervalAndRearm(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "site") + gone := filepath.Join(parent, "gone") + for _, d := range []string{dir, gone} { + if err := os.Mkdir(d, 0o700); err != nil { + t.Fatal(err) + } + } + r := newEventRecorder() + w, err := NewDirWatcher(r.options(testPollInterval)) + if err != nil { + t.Fatal(err) + } + w.Start() + defer w.Close() + w.Watch(dir) + w.Watch(gone) + if !waitFor(t, 5*time.Second, func() bool { return r.intervals.Load() > 2 }) { + t.Fatal("expected interval calls on the backstop cadence") + } + // the platform drops the watch with the directory; rearm then forgets it + if err := os.Remove(gone); err != nil { + t.Fatal(err) + } + forgotten := func() bool { + w.mtx.RLock() + defer w.mtx.RUnlock() + _, ok := w.dirs[gone] + return !ok + } + if !waitFor(t, 5*time.Second, forgotten) { + t.Fatal("expected the removed directory to be forgotten") + } + // and a recreated directory is armed by the next Watch + if err := os.Mkdir(gone, 0o700); err != nil { + t.Fatal(err) + } + w.Watch(gone) + path := filepath.Join(gone, "a.txt") + if err := os.WriteFile(path, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(path) }) { + t.Fatal("expected an event in the recreated directory") + } +} + +// armed returns the directories the platform is watching, which trail the watched set +func (w *DirWatcher) armed() []string { + w.mtx.RLock() + defer w.mtx.RUnlock() + if w.events == nil { + return nil + } + return w.events.WatchList() +} + +func TestDirWatcherUnwatch(t *testing.T) { + dir := t.TempDir() + r := newEventRecorder() + w, err := NewDirWatcher(r.options(time.Hour)) + if err != nil { + t.Fatal(err) + } + w.Unwatch(dir) // not watched + w.Watch(dir) + w.Unwatch(dir) // while stopped, there is nothing armed to disarm + if w.Watched() != 0 || len(w.stale) != 0 { + t.Fatalf("expected no watched directories, got %d", w.Watched()) + } + w.Start() + defer w.Close() + w.Watch(dir) + if w.Watched() != 1 || len(w.armed()) != 1 { + t.Fatalf("expected one watched directory, got %d", w.Watched()) + } + // no longer wanted at once, and disarmed soon after, by the watch goroutine + w.Unwatch(dir) + if w.Watched() != 0 { + t.Fatal("expected the directory to leave the watched set immediately") + } + if !waitFor(t, 5*time.Second, func() bool { return len(w.armed()) == 0 }) { + t.Fatal("expected the directory to be disarmed") + } + path := filepath.Join(dir, "a.txt") + if err := os.WriteFile(path, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + // a second, still-watched directory proves events flow while the first stays silent + other := t.TempDir() + w.Watch(other) + marker := filepath.Join(other, "b.txt") + if err := os.WriteFile(marker, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(marker) }) { + t.Fatal("expected an event in the watched directory") + } + if r.seen(path) { + t.Error("expected no event from a disarmed directory") + } + // removing the directory first makes the platform drop the watch itself + if err := os.RemoveAll(other); err != nil { + t.Fatal(err) + } + w.Unwatch(other) + if w.Watched() != 0 { + t.Error("expected the removed directory to be forgotten") + } + if !waitFor(t, 5*time.Second, func() bool { + w.mtx.RLock() + defer w.mtx.RUnlock() + return len(w.stale) == 0 + }) { + t.Error("expected a directory the platform already dropped to be let go of all the same") + } +} + +func TestDirWatcherUnwatchNeverWaitsOnThePlatform(t *testing.T) { + const dirs = 64 + r := newEventRecorder() + // the watch goroutine is parked in the event handler, so that whatever happens meanwhile + // can only have been done by the callers of Watch and Unwatch themselves + parked, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + o := r.options(time.Hour) + o.OnEvent = func(path string) { + once.Do(func() { + close(parked) + <-release + }) + r.onEvent(path) + } + w, err := NewDirWatcher(o) + if err != nil { + t.Fatal(err) + } + w.Start() + defer w.Close() + parent := t.TempDir() + paths := make([]string, dirs) + for i := range paths { + paths[i] = filepath.Join(parent, "d"+strconv.Itoa(i)) + if err := os.Mkdir(paths[i], 0o700); err != nil { + t.Fatal(err) + } + w.Watch(paths[i]) + } + if err := os.WriteFile(filepath.Join(paths[1], "park.txt"), []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + <-parked + + // and with the platform held against them besides, as it is while a call to it is under way, + // unwatching every directory, and wanting one back, must still go straight through + w.platform.Lock() + unwatched := make(chan struct{}) + go func() { + defer close(unwatched) + for _, path := range paths { + w.Unwatch(path) + } + }() + select { + case <-unwatched: + case <-time.After(5 * time.Second): + t.Fatal("Unwatch waited on the platform") + } + w.mtx.RLock() + stale := len(w.stale) + w.mtx.RUnlock() + if w.Watched() != 0 || stale != dirs || len(w.armed()) != dirs { + t.Fatalf("expected every directory to be unwanted at once and none disarmed yet, got %d stale %d armed", + stale, len(w.armed())) + } + // one wanted again before it is disarmed is kept as it is, without being armed afresh + kept := paths[0] + rewatched := make(chan struct{}) + go func() { + defer close(rewatched) + w.Watch(kept) + }() + select { + case <-rewatched: + case <-time.After(5 * time.Second): + t.Fatal("wanting back a directory that was still armed waited on the platform") + } + w.platform.Unlock() + w.mtx.RLock() + _, stillStale := w.stale[kept] + w.mtx.RUnlock() + if stillStale || w.Watched() != 1 || len(w.armed()) != dirs { + t.Fatal("expected the directory to be wanted again with nothing done to the platform") + } + + close(release) + if !waitFor(t, 5*time.Second, func() bool { return len(w.armed()) == 1 }) { + t.Fatalf("expected every other directory to be disarmed, %d remain armed", len(w.armed())) + } + if got := w.armed(); got[0] != kept { + t.Errorf("expected the directory that was wanted again to be the one still armed, got %s", got[0]) + } + file := filepath.Join(kept, "a.txt") + if err := os.WriteFile(file, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(file) }) { + t.Error("expected events from the directory that was wanted again") + } + // stopping disarms everything, so nothing is left to do for what was still stale + w.Unwatch(kept) + w.Close() + if len(w.stale) != 0 { + t.Error("expected nothing to be left stale once stopped") + } +} + +// parkedDirWatcher returns a started watcher whose watch goroutine is parked in its event +// handler until release is closed, so that a test decides the order of everything that happens +func parkedDirWatcher(t *testing.T, r *eventRecorder) (w *DirWatcher, release chan struct{}) { + t.Helper() + parked, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + o := r.options(time.Hour) + o.OnEvent = func(path string) { + once.Do(func() { + close(parked) + <-release + }) + r.onEvent(path) + } + w, err := NewDirWatcher(o) + if err != nil { + t.Fatal(err) + } + w.Start() + t.Cleanup(w.Close) + bait := t.TempDir() + w.Watch(bait) + if err := os.WriteFile(filepath.Join(bait, "park.txt"), []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + <-parked + return w, release +} + +func (w *DirWatcher) isArmed(dir string) bool { + return slices.Contains(w.armed(), dir) +} + +func (w *DirWatcher) state(dir string) (wanted, live, stale bool) { + w.mtx.RLock() + defer w.mtx.RUnlock() + _, wanted = w.dirs[dir] + _, live = w.live[dir] + _, stale = w.stale[dir] + return wanted, live, stale +} + +func TestDirWatcherWatchOvertakenByUnwatch(t *testing.T) { + r := newEventRecorder() + w, release := parkedDirWatcher(t, r) + defer close(release) + dir := t.TempDir() + // a Watch that has recorded what it wants, and is kept waiting to arm it + w.platform.Lock() + watched := make(chan struct{}) + go func() { + defer close(watched) + w.Watch(dir) + }() + if !waitFor(t, 5*time.Second, func() bool { return w.arming.Load() == 1 }) { + t.Fatal("expected the Watch to be waiting to arm the directory") + } + // is overtaken by an Unwatch of the same directory, which has nothing armed to disarm + w.Unwatch(dir) + if wanted, live, stale := w.state(dir); wanted || live || stale { + t.Fatalf("expected nothing to be left to disarm, got wanted=%t live=%t stale=%t", wanted, live, stale) + } + w.platform.Unlock() + <-watched + // the watch goroutine is parked, so nothing could have disarmed the directory if it had + // been armed: it is unarmed because the Watch found it unwanted and left it alone + if w.isArmed(dir) { + t.Error("expected a directory that was unwanted by the time it could be armed not to be") + } + if wanted, live, stale := w.state(dir); wanted || live || stale || w.Watched() != 1 { + t.Errorf("expected no trace of the directory, got wanted=%t live=%t stale=%t", wanted, live, stale) + } +} + +func TestDirWatcherWatchWaitsForAPendingWatch(t *testing.T) { + r := newEventRecorder() + w, release := parkedDirWatcher(t, r) + dir := t.TempDir() + w.platform.Lock() + returned := make(chan int, 3) + watch := func(n int) { + go func() { + w.Watch(dir) + returned <- n + }() + if !waitFor(t, 5*time.Second, func() bool { return int(w.arming.Load()) == n }) { + t.Fatalf("expected Watch %d to wait for the directory to be armed", n) + } + } + watch(1) + // wanted already, by a Watch that hasn't armed it yet: a second must not take that to + // mean it is watched, and return to a caller that goes on to rely on it + watch(2) + // nor must one that finds it wanted again after an Unwatch, while it is still unarmed + w.Unwatch(dir) + watch(3) + select { + case n := <-returned: + t.Fatalf("Watch %d returned before its directory was armed", n) + default: + } + w.platform.Unlock() + for range 3 { + <-returned + } + if wanted, live, stale := w.state(dir); !wanted || !live || stale || !w.isArmed(dir) { + t.Fatalf("expected the directory to be wanted and armed, got wanted=%t live=%t stale=%t", wanted, live, stale) + } + close(release) + file := filepath.Join(dir, "a.txt") + if err := os.WriteFile(file, []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + if !waitFor(t, 5*time.Second, func() bool { return r.seen(file) }) { + t.Error("expected events from the directory") + } +} + +func TestDirWatcherConcurrentWatchAndUnwatch(t *testing.T) { + r := newEventRecorder() + w, err := NewDirWatcher(r.options(time.Hour)) + if err != nil { + t.Fatal(err) + } + w.Start() + defer w.Close() + parent := t.TempDir() + dirs := make([]string, 8) + for i := range dirs { + dirs[i] = filepath.Join(parent, strconv.Itoa(i)) + if err := os.Mkdir(dirs[i], 0o700); err != nil { + t.Fatal(err) + } + } + var wg sync.WaitGroup + for g := range 8 { + wg.Go(func() { + for i := range 300 { + dir := dirs[(g+i)%len(dirs)] + if (g+i/3)%2 == 0 { + w.Watch(dir) + } else { + w.Unwatch(dir) + } + } + }) + } + wg.Wait() + // however they interleaved, what is armed settles to exactly what is wanted + settled := func() bool { + w.mtx.RLock() + pending := len(w.stale) + wanted := slices.Sorted(maps.Keys(w.dirs)) + live := slices.Sorted(maps.Keys(w.live)) + w.mtx.RUnlock() + armed := w.armed() + slices.Sort(armed) + return pending == 0 && slices.Equal(wanted, live) && slices.Equal(wanted, armed) + } + if !waitFor(t, 10*time.Second, settled) { + t.Fatalf("expected what is armed to settle to what is wanted: %d wanted, %d armed", w.Watched(), len(w.armed())) + } + for _, dir := range dirs { + w.Unwatch(dir) + } + if !waitFor(t, 10*time.Second, func() bool { return settled() && len(w.armed()) == 0 }) { + t.Fatalf("expected nothing to be left armed, got %d", len(w.armed())) + } +} + +func TestDirWatcherWithoutEvents(t *testing.T) { + orig := newEventWatcher + newEventWatcher = func() (*fsnotify.Watcher, error) { return nil, errors.New("unavailable") } + defer func() { newEventWatcher = orig }() + + r := newEventRecorder() + w, err := NewDirWatcher(r.options(testPollInterval)) + if err != nil { + t.Fatal(err) + } + w.Start() + defer w.Close() + w.Watch(t.TempDir()) + if !waitFor(t, 5*time.Second, func() bool { return r.intervals.Load() > 2 }) { + t.Fatal("expected interval calls to continue without fsnotify") + } +} + +func TestDirWatcherErrors(t *testing.T) { + r := newEventRecorder() + w, err := NewDirWatcher(r.options(time.Hour)) + if err != nil { + t.Fatal(err) + } + w.handleError(errors.New("ignored")) + if r.lost.Load() != 0 { + t.Error("expected only an overflow to be reported as lost events") + } + w.handleError(fsnotify.ErrEventOverflow) + if r.lost.Load() != 1 { + t.Error("expected an overflow to be reported as lost events") + } + w.Start() + w.Close() + w.mtx.RLock() + defer w.mtx.RUnlock() + if w.events != nil { + t.Error("expected the event watcher to be released on Close") + } +} + +func TestDirWatcherClosedChannels(t *testing.T) { + r := newEventRecorder() + w, err := NewDirWatcher(r.options(testPollInterval)) + if err != nil { + t.Fatal(err) + } + w.Start() + defer w.Close() + w.mtx.RLock() + events := w.events + w.mtx.RUnlock() + // closing the event source out from under the loop must not spin or stop it + events.Close() + before := r.intervals.Load() + if !waitFor(t, 5*time.Second, func() bool { return r.intervals.Load() > before+1 }) { + t.Fatal("expected the loop to keep running after its event source closed") + } +} diff --git a/testdata/test.full.02.conf b/testdata/test.full.02.conf index cfc91de69..b5a5c8e36 100644 --- a/testdata/test.full.02.conf +++ b/testdata/test.full.02.conf @@ -72,7 +72,7 @@ backends: revalidation_factor: 2 multipart_ranges_disabled: true dearticulate_upstream_ranges: true - compressable_types: + compressible_types: - image/png provider: test_type cache_name: test diff --git a/testdata/test.full.conf b/testdata/test.full.conf index d517ac197..9121b8bd5 100644 --- a/testdata/test.full.conf +++ b/testdata/test.full.conf @@ -72,7 +72,7 @@ backends: revalidation_factor: 2 multipart_ranges_disabled: true dearticulate_upstream_ranges: true - compressable_types: + compressible_types: - image/png provider: test_type cache_name: test diff --git a/testdata/test.full.tls.conf b/testdata/test.full.tls.conf index 277893938..91a85e02b 100644 --- a/testdata/test.full.tls.conf +++ b/testdata/test.full.tls.conf @@ -72,7 +72,7 @@ backends: revalidation_factor: 2 multipart_ranges_disabled: true dearticulate_upstream_ranges: true - compressable_types: + compressible_types: - image/png provider: test_type cache_name: test diff --git a/testdata/test.invalid-pcf-name.conf b/testdata/test.invalid-pcf-name.conf index 4e3ab9343..fab265673 100644 --- a/testdata/test.invalid-pcf-name.conf +++ b/testdata/test.invalid-pcf-name.conf @@ -72,7 +72,7 @@ backends: revalidation_factor: 2 multipart_ranges_disabled: true dearticulate_upstream_ranges: true - compressable_types: + compressible_types: - image/png provider: test_type cache_name: test From 9fd081b533f5b696ef95425e1cda5cb1824df327 Mon Sep 17 00:00:00 2001 From: James Ranson Date: Sun, 20 Sep 2026 21:16:43 -0600 Subject: [PATCH 2/2] update devenv Signed-off-by: James Ranson --- docs/developer/environment/README.md | 10 ++++++++++ docs/developer/environment/static-site/index.html | 12 ++++++++++++ .../environment/trickster-config/trickster.yaml | 6 ++++++ integration/harness_test.go | 13 +++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 docs/developer/environment/static-site/index.html diff --git a/docs/developer/environment/README.md b/docs/developer/environment/README.md index e7e1fa7d5..752a29c21 100644 --- a/docs/developer/environment/README.md +++ b/docs/developer/environment/README.md @@ -32,6 +32,16 @@ You can stop the developer environment by running `make developer-stop`. To delete the developer environment, run `make developer-delete` which will destroy all data including named volumes. +## Static File Server + +The `static1` backend in `trickster-config/trickster.yaml` serves the files under +[static-site](./static-site/) with Trickster's [Static File Server](../../static.md) +backend provider, at . It needs no container. Files +added or changed under `static-site` are picked up while Trickster runs, with no restart. + +The site's `root` is relative to the root of the repo, which is where `make serve-dev` +runs Trickster from. + ## Graphite The environment runs a Graphite origin (`graphiteapp/graphite-statsd`: carbon-cache diff --git a/docs/developer/environment/static-site/index.html b/docs/developer/environment/static-site/index.html new file mode 100644 index 000000000..6bb50dd6e --- /dev/null +++ b/docs/developer/environment/static-site/index.html @@ -0,0 +1,12 @@ + + + + + + Trickster Static Backend + + +

Hello, World!

+

Served by the static1 backend of your local Trickster developer instance.

+ + diff --git a/docs/developer/environment/trickster-config/trickster.yaml b/docs/developer/environment/trickster-config/trickster.yaml index b760990b3..3afd68481 100644 --- a/docs/developer/environment/trickster-config/trickster.yaml +++ b/docs/developer/environment/trickster-config/trickster.yaml @@ -241,6 +241,12 @@ backends: - path: / match_type: prefix handler: proxycache + # static1 serves the files under static-site at http://127.0.0.1:8480/static1/ + static1: + provider: static + static: + # relative to the root of the repo, where make serve-dev runs Trickster + root: docs/developer/environment/static-site mecone-rp: provider: reverseproxy origin_url: 'http://127.0.0.1:8497' diff --git a/integration/harness_test.go b/integration/harness_test.go index 089e7c3ae..f47a30ab4 100644 --- a/integration/harness_test.go +++ b/integration/harness_test.go @@ -287,6 +287,14 @@ func writeTestConfig(t *testing.T, configPath string, bo.ListenerNames = []string{listener.DefaultFrontendName, "influx3-flight"} } } + // the dev config's static roots are relative to the repo root, not to this package + for _, bo := range c.Backends { + if bo != nil && bo.Static != nil && bo.Static.Root != "" && !filepath.IsAbs(bo.Static.Root) { + if root := filepath.Join("..", bo.Static.Root); isDir(root) { + bo.Static.Root = root + } + } + } c.Frontend = nil c.Metrics = nil c.MgmtConfig.ListenAddress = "" @@ -301,6 +309,11 @@ func writeTestConfig(t *testing.T, configPath string, return path } +func isDir(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} + func defaultCacheProviders() []cacheProviderCase { return []cacheProviderCase{ {Name: "memory", Backend: "prom1"},