feat(cache): add Cloudflare SigV4 S3 signature compatibility fix and compile locally
Some checks failed
Go CI with S3 Caching / build-and-test (push) Failing after 4s
Some checks failed
Go CI with S3 Caching / build-and-test (push) Failing after 4s
This commit is contained in:
@@ -50,8 +50,8 @@ runs:
|
||||
if: ${{ inputs.cache == 'true' && inputs.s3-bucket != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Installing go-cache-plugin..."
|
||||
go install github.com/tailscale/go-cache-plugin/cmd/go-cache-plugin@${{ inputs.go-cache-plugin-version }}
|
||||
echo "Installing custom Cloudflare-compatible go-cache-plugin..."
|
||||
go build -o $HOME/go/bin/go-cache-plugin ${{ github.action_path }}/go-cache-plugin-src/cmd/go-cache-plugin
|
||||
|
||||
- name: Configure Go Cache
|
||||
if: ${{ inputs.cache == 'true' && inputs.s3-bucket != '' }}
|
||||
|
||||
28
go-cache-plugin-src/.github/workflows/go-presubmit.yml
vendored
Normal file
28
go-cache-plugin-src/.github/workflows/go-presubmit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Go presubmit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Go presubmit
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['stable']
|
||||
os: ['ubuntu-latest']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- uses: creachadair/go-presubmit-action@v2
|
||||
28
go-cache-plugin-src/LICENSE
Normal file
28
go-cache-plugin-src/LICENSE
Normal file
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2024 Tailscale Inc & AUTHORS.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
94
go-cache-plugin-src/README.md
Normal file
94
go-cache-plugin-src/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# go-cache-plugin
|
||||
|
||||
[](https://pkg.go.dev/github.com/tailscale/go-cache-plugin)
|
||||
[](https://github.com/tailscale/go-cache-plugin/actions/workflows/go-presubmit.yml)
|
||||
|
||||
This repository defines a tool implementing a `GOCACHEPROG` plugin backed by Amazon S3.
|
||||
|
||||
## Installation
|
||||
|
||||
```shell
|
||||
go install github.com/tailscale/go-cache-plugin/cmd/go-cache-plugin@latest
|
||||
```
|
||||
|
||||
## Usage Outline
|
||||
|
||||
```shell
|
||||
export GOCACHEPROG="go-cache-plugin --cache-dir=/tmp/gocache --bucket=some-s3-bucket"
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Using the plugin requires a Go toolchain built with `GOEXPERIMENT=cacheprog` enabled.
|
||||
However, you do not need the experiment enabled to build the plugin itself.
|
||||
|
||||
## Discussion
|
||||
|
||||
The `go-cache-plugin` program supports two modes of operation:
|
||||
|
||||
1. **Direct mode**: The program is invoked directly by the Go toolchain as a
|
||||
subprocess, and exits when the toolchain execution ends.
|
||||
|
||||
This is the default mode of operation, and requires no additional setup.
|
||||
|
||||
2. **Server mode**: The program runs as a separate process and the Go toolchain
|
||||
communicates with it over a local socket.
|
||||
|
||||
This mode requires the server to be started up ahead of time, but makes the
|
||||
configuration for the toolchain simpler. This mode also permits running an
|
||||
in-process module and sum database proxy.
|
||||
|
||||
### Server Mode
|
||||
|
||||
To run in server mode, use the `serve` subcommand:
|
||||
|
||||
```sh
|
||||
# N.B.: The --plugin flag is required.
|
||||
go-cache-plugin serve \
|
||||
--plugin=5930 \
|
||||
--cache-dir=/tmp/gocache \
|
||||
--bucket=some-s3-bucket
|
||||
```
|
||||
|
||||
To connect to a server running in this mode, use the `connect` subcommand:
|
||||
|
||||
```sh
|
||||
# Use the same port given to the server's --plugin flag.
|
||||
# Mnemonic: 5930 == (Go) (C)ache (P)lugin
|
||||
export GOCACHEPROG="go-cache-plugin connect 5930"
|
||||
go build ./...
|
||||
```
|
||||
|
||||
The `connect` command just bridges the socket to stdin/stdout, which is how the
|
||||
Go toolchain expects to talk to the plugin.
|
||||
|
||||
### Running a Module Proxy
|
||||
|
||||
To enable a caching module proxy, use the `--modproxy` flag to `serve`. The
|
||||
module proxy uses HTTP, not the plugin interface, use `--http` to set the address:
|
||||
|
||||
```sh
|
||||
go-cache-plugin serve \
|
||||
--plugin=5930 \
|
||||
--http=localhost:5970 --modproxy \
|
||||
--cache-dir=/tmp/gocache \
|
||||
# ... other flags
|
||||
```
|
||||
|
||||
To tell the Go toolchain about the proxy, set:
|
||||
|
||||
```sh
|
||||
# Mnemonic: 5970 == (Go) (M)odule (P)roxy
|
||||
export GOPROXY=http://localhost:5970/mod # use the --http address
|
||||
```
|
||||
|
||||
If you want to also proxy queries to `sum.golang.org`, also add:
|
||||
|
||||
```sh
|
||||
export GOSUMDB='sum.golang.org http://locahost:5970/mod/sumdb/sum.golang.org'
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Cache plugin protocol (proposal)](https://github.com/golang/go/issues/59719)
|
||||
- [Cache plugin library](https://github.com/creachadair/gocache)
|
||||
- [Go module proxy documentation](https://proxy.golang.org)
|
||||
29
go-cache-plugin-src/cmd/go-cache-plugin/addca_default.go
Normal file
29
go-cache-plugin-src/cmd/go-cache-plugin/addca_default.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/creachadair/atomicfile"
|
||||
"github.com/creachadair/command"
|
||||
"github.com/creachadair/tlsutil"
|
||||
)
|
||||
|
||||
func installSigningCert(env *command.Env, cert tlsutil.Certificate) error {
|
||||
const certFile = "revproxy-ca.crt"
|
||||
if err := atomicfile.WriteData(certFile, cert.CertPEM(), 0644); err != nil {
|
||||
log.Printf("WARNING: Unable to write cert file: %v", err)
|
||||
} else {
|
||||
log.Printf("Wrote signing cert to %s", certFile)
|
||||
}
|
||||
// TODO(creachadair): Maybe crib some other cases from mkcert, if we need
|
||||
// them, for example:
|
||||
// https://github.com/FiloSottile/mkcert/blob/master/truststore_darwin.go
|
||||
|
||||
return errors.New("unable to install a certificate on this system")
|
||||
}
|
||||
39
go-cache-plugin-src/cmd/go-cache-plugin/addca_linux.go
Normal file
39
go-cache-plugin-src/cmd/go-cache-plugin/addca_linux.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/creachadair/command"
|
||||
"github.com/creachadair/tlsutil"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func installSigningCert(env *command.Env, cert tlsutil.Certificate) error {
|
||||
const ubuntuCertFile = "/etc/ssl/certs/ca-certificates.crt"
|
||||
return lockAndAppend(ubuntuCertFile, cert.CertPEM())
|
||||
}
|
||||
|
||||
// lockAndAppend acquires an exclusive advisory lock on path, if possible, and
|
||||
// appends data to the end of it. It reports an error if path does not exist,
|
||||
// or if the lock could not be acquired. The lock is automatically released
|
||||
// before returning.
|
||||
func lockAndAppend(path string, data []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fd := int(f.Fd())
|
||||
if err := unix.Flock(fd, unix.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("lock: %w", err)
|
||||
}
|
||||
defer unix.Flock(fd, unix.LOCK_UN)
|
||||
_, werr := f.Write(data)
|
||||
cerr := f.Close()
|
||||
return errors.Join(werr, cerr)
|
||||
}
|
||||
213
go-cache-plugin-src/cmd/go-cache-plugin/commands.go
Normal file
213
go-cache-plugin-src/cmd/go-cache-plugin/commands.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/command"
|
||||
"github.com/creachadair/gocache"
|
||||
"github.com/creachadair/taskgroup"
|
||||
)
|
||||
|
||||
var flags struct {
|
||||
CacheDir string `flag:"cache-dir,default=$GOCACHE_DIR,Local cache directory (required)"`
|
||||
S3Bucket string `flag:"bucket,default=$GOCACHE_S3_BUCKET,S3 bucket name (required)"`
|
||||
S3Region string `flag:"region,default=$GOCACHE_S3_REGION,S3 region"`
|
||||
S3Endpoint string `flag:"s3-endpoint-url,default=$GOCACHE_S3_ENDPOINT_URL,S3 custom endpoint URL (if unset, use AWS default)"`
|
||||
S3PathStyle bool `flag:"s3-path-style,default=$GOCACHE_S3_PATH_STYLE,S3 path-style URLs (optional)"`
|
||||
KeyPrefix string `flag:"prefix,default=$GOCACHE_KEY_PREFIX,S3 key prefix (optional)"`
|
||||
MinUploadSize int64 `flag:"min-upload-size,default=$GOCACHE_MIN_SIZE,Minimum object size to upload to S3 (in bytes)"`
|
||||
Concurrency int `flag:"c,default=$GOCACHE_CONCURRENCY,Maximum number of concurrent requests"`
|
||||
S3Concurrency int `flag:"u,default=$GOCACHE_S3_CONCURRENCY,Maximum concurrency for upload to S3"`
|
||||
PrintMetrics bool `flag:"metrics,default=$GOCACHE_METRICS,Print summary metrics to stderr at exit"`
|
||||
Expiration time.Duration `flag:"expiry,default=$GOCACHE_EXPIRY,Cache expiration period (optional)"`
|
||||
Verbose bool `flag:"v,default=$GOCACHE_VERBOSE,Enable verbose logging"`
|
||||
DebugLog int `flag:"debug,default=$GOCACHE_DEBUG,Enable detailed per-request debug logging (noisy)"`
|
||||
}
|
||||
|
||||
const (
|
||||
debugBuildCache = 1 << iota
|
||||
debugModProxy
|
||||
debugRevProxy
|
||||
)
|
||||
|
||||
// runDirect runs a cache communicating on stdin/stdout, for use as a direct
|
||||
// GOCACHEPROG plugin.
|
||||
func runDirect(env *command.Env) error {
|
||||
s, _, err := initCacheServer(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Run(env.Context(), os.Stdin, os.Stdout); err != nil {
|
||||
return fmt.Errorf("cache server exited with error: %w", err)
|
||||
}
|
||||
if flags.Verbose || flags.PrintMetrics {
|
||||
fmt.Fprintln(os.Stderr, s.Metrics())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var serveFlags struct {
|
||||
Plugin int `flag:"plugin,default=$GOCACHE_PLUGIN,Plugin service port (required)"`
|
||||
HTTP string `flag:"http,default=$GOCACHE_HTTP,HTTP service address ([host]:port)"`
|
||||
ModProxy bool `flag:"modproxy,default=$GOCACHE_MODPROXY,Enable a Go module proxy (requires --http)"`
|
||||
RevProxy string `flag:"revproxy,default=$GOCACHE_REVPROXY,Reverse proxy these hosts (comma-separated; requires --http)"`
|
||||
SumDB string `flag:"sumdb,default=$GOCACHE_SUMDB,SumDB servers to proxy for (comma-separated)"`
|
||||
}
|
||||
|
||||
func noopClose(context.Context) error { return nil }
|
||||
|
||||
// runServe runs a cache communicating over a local TCP socket.
|
||||
func runServe(env *command.Env) error {
|
||||
if serveFlags.Plugin <= 0 {
|
||||
return env.Usagef("you must provide a --plugin port")
|
||||
}
|
||||
|
||||
// Initialize the cache server. Unlike a direct server, only close down and
|
||||
// wait for cache cleanup when the whole process exits.
|
||||
s, s3c, err := initCacheServer(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closeHook := s.Close
|
||||
s.Close = noopClose
|
||||
|
||||
// Listen for connections from the Go toolchain on the specified socket.
|
||||
lst, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", serveFlags.Plugin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
log.Printf("plugin listening at %q", lst.Addr())
|
||||
|
||||
ctx, cancel := signal.NotifyContext(env.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
var g taskgroup.Group
|
||||
g.Run(func() {
|
||||
<-ctx.Done()
|
||||
log.Printf("closing plugin listener")
|
||||
lst.Close()
|
||||
})
|
||||
|
||||
// If a module proxy is enabled, start it.
|
||||
modProxy, modCleanup, err := initModProxy(env.SetContext(ctx), s3c)
|
||||
if err != nil {
|
||||
lst.Close()
|
||||
return fmt.Errorf("module proxy: %w", err)
|
||||
}
|
||||
defer modCleanup()
|
||||
|
||||
// If a reverse proxy is enabled, start it.
|
||||
revProxy, err := initRevProxy(env.SetContext(ctx), s3c, &g)
|
||||
if err != nil {
|
||||
lst.Close()
|
||||
return fmt.Errorf("reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
// If an HTTP server is enabled, start it up with debug routes
|
||||
// and whatever other services were requested.
|
||||
if serveFlags.HTTP != "" {
|
||||
srv := &http.Server{
|
||||
Addr: serveFlags.HTTP,
|
||||
Handler: makeHandler(modProxy, revProxy),
|
||||
}
|
||||
g.Go(srv.ListenAndServe)
|
||||
vprintf("HTTP server listening at %q", serveFlags.HTTP)
|
||||
g.Run(func() {
|
||||
<-ctx.Done()
|
||||
vprintf("stopping HTTP service")
|
||||
srv.Shutdown(context.Background())
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := lst.Accept()
|
||||
if err != nil {
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("accept failed: %v, exiting server loop", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
log.Printf("new client connection")
|
||||
g.Go(func() error {
|
||||
defer func() {
|
||||
log.Printf("client connection closed")
|
||||
conn.Close()
|
||||
}()
|
||||
return s.Run(ctx, conn, conn)
|
||||
})
|
||||
}
|
||||
log.Printf("server loop exited, waiting for client exit")
|
||||
g.Wait()
|
||||
if closeHook != nil {
|
||||
ctx := gocache.WithLogf(context.Background(), log.Printf)
|
||||
if err := closeHook(ctx); err != nil {
|
||||
log.Printf("server close: %v (ignored)", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runConnect implements a direct cache proxy by connecting to a remote server.
|
||||
func runConnect(env *command.Env, plugin string) error {
|
||||
port, err := strconv.Atoi(plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid plugin port: %w", err)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
start := time.Now()
|
||||
vprintf("connected to %q", conn.RemoteAddr())
|
||||
|
||||
out := taskgroup.Go(func() error {
|
||||
defer conn.(*net.TCPConn).CloseWrite() // let the server finish
|
||||
return copy(conn, os.Stdin)
|
||||
})
|
||||
if rerr := copy(os.Stdout, conn); rerr != nil {
|
||||
vprintf("read responses: %v", err)
|
||||
}
|
||||
out.Wait()
|
||||
conn.Close()
|
||||
vprintf("connection closed (%v elapsed)", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// copy emulates the base case of io.Copy, but does not attempt to use the
|
||||
// io.ReaderFrom or io.WriterTo implementations.
|
||||
//
|
||||
// TODO(creachadair): For some reason io.Copy does not work correctly when r is
|
||||
// a pipe (e.g., stdin) and w is a TCP socket. Figure out why.
|
||||
func copy(w io.Writer, r io.Reader) error {
|
||||
var buf [4096]byte
|
||||
for {
|
||||
nr, err := r.Read(buf[:])
|
||||
if nr > 0 {
|
||||
if nw, err := w.Write(buf[:nr]); err != nil {
|
||||
return fmt.Errorf("copy to: %w", err)
|
||||
} else if nw < nr {
|
||||
return fmt.Errorf("wrote %d < %d bytes: %w", nw, nr, io.ErrShortWrite)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("copy from: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
101
go-cache-plugin-src/cmd/go-cache-plugin/go-cache-plugin.go
Normal file
101
go-cache-plugin-src/cmd/go-cache-plugin/go-cache-plugin.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Program gocache implements the experimental GOCACHEPROG protocol over an S3
|
||||
// bucket, for use in builder and CI workers.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/creachadair/command"
|
||||
"github.com/creachadair/flax"
|
||||
"github.com/tailscale/go-cache-plugin/lib/s3util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||
root := &command.C{
|
||||
Name: command.ProgramName(),
|
||||
Usage: "--cache-dir d --bucket b [options]\nhelp",
|
||||
Help: `Run a cache service for the Go toolchain backed by an S3 bucket.
|
||||
|
||||
This program serves the Go toolchain cache protocol on stdin/stdout.
|
||||
It is meant to be run by the "go" tool as a GOCACHEPROG plugin.
|
||||
For example:
|
||||
|
||||
GOCACHEPROG=` + command.ProgramName() + ` go build ./...
|
||||
|
||||
Note that Go toolchains prior to 1.24 must be built with GOEXPERIMENT=cacheprog
|
||||
to enable this integration. Go 1.24 and later have it enabled by default.
|
||||
|
||||
You must provide --cache-dir, --bucket, and --region, or the corresponding
|
||||
environment variables (see "help environment"). Entries in the cache are
|
||||
stored in the specified S3 bucket, and staged in a local directory specified by
|
||||
the --cache-dir flag or GOCACHE_DIR environment.`,
|
||||
|
||||
SetFlags: command.Flags(flax.MustBind, &flags),
|
||||
Run: command.Adapt(runDirect),
|
||||
|
||||
Commands: []*command.C{
|
||||
{
|
||||
Name: "serve",
|
||||
Usage: "--plugin <port>",
|
||||
Help: `Run a cache server.
|
||||
|
||||
In this mode, the cache server listens for connections on a socket instead of
|
||||
serving directly over stdin/stdout. The "connect" command adapts the direct
|
||||
interface to this one.
|
||||
|
||||
By default, only the build cache is exported via the --plugin port.
|
||||
|
||||
If --http is set, the server also exports an HTTP server at that address.
|
||||
By default, this exports only /debug endpoints, including metrics.
|
||||
When --http is enabled, the following options are available:
|
||||
|
||||
- When --modcache is true, the server also exports a caching module proxy at
|
||||
http://<host>:<port>/mod/.
|
||||
|
||||
- When --revproxy is set, the server also hosts a caching reverse proxy for the
|
||||
specified hosts at http://<host>:<port>. The reverse proxy handles both HTTP
|
||||
and HTTPS requests, and caches immutable successful responses.`,
|
||||
|
||||
SetFlags: command.Flags(flax.MustBind, &serveFlags),
|
||||
Run: command.Adapt(runServe),
|
||||
},
|
||||
{
|
||||
Name: "connect",
|
||||
Usage: "<port>",
|
||||
Help: `Connect to a remote cache server.
|
||||
|
||||
This mode bridges stdin/stdout to a cache server (see the "serve" command)
|
||||
listening on the specified port.`,
|
||||
|
||||
Run: command.Adapt(runConnect),
|
||||
},
|
||||
command.HelpCommand(helpTopics),
|
||||
command.VersionCommand(),
|
||||
},
|
||||
}
|
||||
command.RunOrFail(root.NewEnv(nil), os.Args[1:])
|
||||
}
|
||||
|
||||
// getBucketRegion reports the specified region for the given bucket.
|
||||
// if the --region flag was set, that value is returned without error.
|
||||
// Otherwise, it queries the GetBucketLocation API.
|
||||
func getBucketRegion(ctx context.Context, bucket string) (string, error) {
|
||||
if flags.S3Region != "" {
|
||||
return flags.S3Region, nil
|
||||
}
|
||||
return s3util.BucketRegion(ctx, bucket)
|
||||
}
|
||||
|
||||
// vprintf acts as log.Printf if the --verbose flag is set; otherwise it
|
||||
// discards its input.
|
||||
func vprintf(msg string, args ...any) {
|
||||
if flags.Verbose || flags.DebugLog != 0 {
|
||||
log.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
153
go-cache-plugin-src/cmd/go-cache-plugin/help.go
Normal file
153
go-cache-plugin-src/cmd/go-cache-plugin/help.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/creachadair/command"
|
||||
|
||||
var helpTopics = []command.HelpTopic{
|
||||
{
|
||||
Name: "configure",
|
||||
Help: `How to configure the plugin.
|
||||
|
||||
To run the plugin, install the program somewhere on your system and set the
|
||||
GOCACHEPROG environment variable to the command line of the plugin. You can
|
||||
either specify the full path to the program, or install it in your $PATH.
|
||||
|
||||
Parameters can be passed either as flags or via environment variables.
|
||||
See also "help environment".
|
||||
|
||||
The plugin requires credentials to access S3. If you are running in AWS, it can
|
||||
get credentials from the instance metadata service; otherwise you will need to
|
||||
plumb AWS environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
|
||||
AWS_ENDPOINT_URL) or set up a configuration file.
|
||||
|
||||
See also: "help environment".
|
||||
Related: "direct-mode", "serve-mode", "module-proxy", "reverse-proxy".`,
|
||||
},
|
||||
{
|
||||
Name: "environment",
|
||||
Help: `Environment variables understood by this program.
|
||||
|
||||
To make it easier to configure this tool for multiple workflows, most of the
|
||||
settings can be set via environment variables as well as flags.
|
||||
|
||||
--------------------------------------------------------------------
|
||||
Flag (global) Variable Format Default
|
||||
--------------------------------------------------------------------
|
||||
--cache-dir GOCACHE_DIR path (required)
|
||||
--bucket GOCACHE_S3_BUCKET string (required)
|
||||
--region GOCACHE_S3_REGION string based on bucket
|
||||
--s3-path-style GOCACHE_S3_PATH_STYLE bool false
|
||||
--s3-endpoint-url GOCACHE_S3_ENDPOINT_URL string ""
|
||||
--prefix GOCACHE_KEY_PREFIX string ""
|
||||
--min-upload-size GOCACHE_MIN_SIZE int64 0
|
||||
--metrics GOCACHE_METRICS bool false
|
||||
--expiry GOCACHE_EXPIRY duration 0
|
||||
-c GOCACHE_CONCURRENCY int runtime.NumCPU
|
||||
-u GOCACHE_S3_CONCURRENCY duration runtime.NumCPU
|
||||
-v GOCACHE_VERBOSE bool false
|
||||
--debug GOCACHE_DEBUG int 0 (see "help debug")
|
||||
|
||||
--------------------------------------------------------------------
|
||||
Flag (serve) Variable Format Default
|
||||
--------------------------------------------------------------------
|
||||
--plugin GOCACHE_PLUGIN port (required)
|
||||
--http GOCACHE_HTTP [host]:port ""
|
||||
--modproxy GOCACHE_MODPROXY bool false
|
||||
--revproxy GOCACHE_REVPROXY host,... ""
|
||||
--sumdb GOCACHE_SUMDB host,... ""
|
||||
|
||||
See also: "help configure".`,
|
||||
},
|
||||
{
|
||||
Name: "direct-mode",
|
||||
Help: `Run the plugin directly as a subprocess of the toolchain.
|
||||
|
||||
export GOCACHEPROG="go-cache-plugin --cache-dir=/tmp/gocache --bucket ..."
|
||||
go build ...
|
||||
|
||||
Alternatively:
|
||||
|
||||
export GOCACHE_DIR=/tmp/gocache
|
||||
export GOCACHE_S3_BUCKET=cache-bucket-name
|
||||
export GOCACHEPROG=go-cache-plugin
|
||||
go build ...
|
||||
|
||||
In this mode, you must specify the --cache-dir and --bucket settings.`,
|
||||
},
|
||||
{
|
||||
Name: "serve-mode",
|
||||
Help: `Run the plugin as a standalone service.
|
||||
|
||||
Use the "serve" subcommand:
|
||||
|
||||
go-cache-plugin serve \
|
||||
--cache-dir=/tmp/gocache --bucket=$B \
|
||||
--plugin $PORT
|
||||
|
||||
You can then use the "connect" subcommand to wire up the toolchain:
|
||||
|
||||
export GOCACHEPROG="go-cache-plugin connect $PORT"
|
||||
|
||||
In this mode, the server must have credentials to access to S3, but the
|
||||
toolchain process does not need AWS credentials.`,
|
||||
},
|
||||
{
|
||||
Name: "module-proxy",
|
||||
Help: `Run a Go module and sum database proxy.
|
||||
|
||||
With the --modproxy flag, the server will also export an HTTP proxy for the
|
||||
public Go module proxy (proxy.golang.org) and sum DB (sum.golang.org) at the
|
||||
given address:
|
||||
|
||||
go-cache-plugin serve ... --http=localhost:5970 --modproxy
|
||||
|
||||
To use the module proxy, set the standard GOPROXY environment variable.
|
||||
The module proxy serves under the path "/mod/":
|
||||
|
||||
export GOPROXY=http://localhost:5970/mod
|
||||
export GOCACHEPROG="go-cache-plugin connect $PORT"
|
||||
go build ...
|
||||
|
||||
To use the sum DB proxy, set the GOSUMDB environment variable.
|
||||
The proxy path for a given sumdb (e.g., sum.golang.org) has this format:
|
||||
|
||||
export GOSUMDB="sum.golang.org http://localhost:5970/mod/sumdb/sum.golang.org"
|
||||
|
||||
See also: https://proxy.golang.org/`,
|
||||
},
|
||||
{
|
||||
Name: "reverse-proxy",
|
||||
Help: `Run a caching reverse proxy.
|
||||
|
||||
With the --revproxy flag, the server will also export a caching reverse
|
||||
proxy for the specified hosts, given as a comma-separated list:
|
||||
|
||||
go-cache-plugin serve ... \
|
||||
--http=localhost:5970 \
|
||||
--revproxy='api.example.com,www.example.com'
|
||||
|
||||
When this is enabled, you can configure this address as an HTTP proxy:
|
||||
|
||||
HTTPS_PROXY=localhost:5970 curl https://api.example.com/foo
|
||||
|
||||
The proxy supports both HTTP and HTTPS backends. For HTTPS proxy targets, the
|
||||
server generates its own TLS certificate, and tries to install a custom signing
|
||||
cert so that other tools will validate it. The ability to do this varies by
|
||||
system and configuration, however.`,
|
||||
},
|
||||
{
|
||||
Name: "debug",
|
||||
Help: `Enable detailed debug logging.
|
||||
|
||||
The --debug flag enables (very) verbose debug logging for the components of the
|
||||
cache plugin. The value of the flag is a bit mask of:
|
||||
|
||||
1: Go build cache
|
||||
2: Go module proxy and sum database
|
||||
4: HTTP reverse proxy
|
||||
|
||||
The default is 0 (no debug logging).`,
|
||||
},
|
||||
}
|
||||
319
go-cache-plugin-src/cmd/go-cache-plugin/setup.go
Normal file
319
go-cache-plugin-src/cmd/go-cache-plugin/setup.go
Normal file
@@ -0,0 +1,319 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/smithy-go/middleware"
|
||||
smithyhttp "github.com/aws/smithy-go/transport/http"
|
||||
"github.com/creachadair/command"
|
||||
"github.com/creachadair/gocache"
|
||||
"github.com/creachadair/gocache/cachedir"
|
||||
"github.com/creachadair/mhttp/proxyconn"
|
||||
"github.com/creachadair/taskgroup"
|
||||
"github.com/creachadair/tlsutil"
|
||||
"github.com/goproxy/goproxy"
|
||||
"github.com/tailscale/go-cache-plugin/lib/gobuild"
|
||||
"github.com/tailscale/go-cache-plugin/lib/modproxy"
|
||||
"github.com/tailscale/go-cache-plugin/lib/revproxy"
|
||||
"github.com/tailscale/go-cache-plugin/lib/s3util"
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
func initCacheServer(env *command.Env) (*gocache.Server, *s3util.Client, error) {
|
||||
switch {
|
||||
case flags.CacheDir == "":
|
||||
return nil, nil, env.Usagef("you must provide a --cache-dir")
|
||||
case flags.S3Bucket == "":
|
||||
return nil, nil, env.Usagef("you must provide an S3 --bucket name")
|
||||
}
|
||||
region, err := getBucketRegion(env.Context(), flags.S3Bucket)
|
||||
if err != nil {
|
||||
return nil, nil, env.Usagef("you must provide an S3 --region name")
|
||||
}
|
||||
|
||||
dir, err := cachedir.New(flags.CacheDir)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create local cache: %w", err)
|
||||
}
|
||||
|
||||
opts := []func(*config.LoadOptions) error{
|
||||
config.WithRegion(region),
|
||||
config.WithResponseChecksumValidation(aws.ResponseChecksumValidationWhenRequired),
|
||||
}
|
||||
if flags.S3Endpoint != "" {
|
||||
vprintf("S3 endpoint URL: %s", flags.S3Endpoint)
|
||||
opts = append(opts, config.WithBaseEndpoint(flags.S3Endpoint))
|
||||
}
|
||||
cfg, err := config.LoadDefaultConfig(env.Context(), opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load AWS config: %w", err)
|
||||
}
|
||||
|
||||
vprintf("local cache directory: %s", flags.CacheDir)
|
||||
vprintf("S3 cache bucket %q (%s)", flags.S3Bucket, region)
|
||||
client := &s3util.Client{
|
||||
Client: s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.UsePathStyle = flags.S3PathStyle
|
||||
|
||||
// Cloudflare SigV4 Compatibility Fix
|
||||
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
|
||||
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
|
||||
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
|
||||
return stack.Finalize.Insert(middleware.FinalizeMiddlewareFunc("RemoveAcceptEncoding",
|
||||
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
|
||||
middleware.FinalizeOutput, middleware.Metadata, error,
|
||||
) {
|
||||
if req, ok := in.Request.(*smithyhttp.Request); ok {
|
||||
req.Header.Del("Accept-Encoding")
|
||||
req.Header.Del("Amz-Sdk-Invocation-Id")
|
||||
req.Header.Del("Amz-Sdk-Request")
|
||||
}
|
||||
return next.HandleFinalize(ctx, in)
|
||||
}), "Signing", middleware.Before)
|
||||
})
|
||||
}),
|
||||
Bucket: flags.S3Bucket,
|
||||
}
|
||||
cache := &gobuild.S3Cache{
|
||||
Local: dir,
|
||||
S3Client: client,
|
||||
KeyPrefix: flags.KeyPrefix,
|
||||
MinUploadSize: flags.MinUploadSize,
|
||||
UploadConcurrency: flags.S3Concurrency,
|
||||
}
|
||||
cache.SetMetrics(env.Context(), expvar.NewMap("gocache_host"))
|
||||
|
||||
close := cache.Close
|
||||
if flags.Expiration > 0 {
|
||||
dirClose := dir.Cleanup(flags.Expiration)
|
||||
close = func(ctx context.Context) error {
|
||||
return errors.Join(cache.Close(ctx), dirClose(ctx))
|
||||
}
|
||||
}
|
||||
s := &gocache.Server{
|
||||
Get: cache.Get,
|
||||
Put: cache.Put,
|
||||
Close: close,
|
||||
SetMetrics: cache.SetMetrics,
|
||||
MaxRequests: flags.Concurrency,
|
||||
Logf: vprintf,
|
||||
LogRequests: flags.DebugLog&debugBuildCache != 0,
|
||||
}
|
||||
expvar.Publish("gocache_server", s.Metrics().Get("server"))
|
||||
return s, client, nil
|
||||
}
|
||||
|
||||
// initModProxy initializes a Go module proxy if one is enabled. If not, it
|
||||
// returns a nil handler without error. The caller must defer a call to the
|
||||
// cleanup function unless an error is reported.
|
||||
func initModProxy(env *command.Env, s3c *s3util.Client) (_ http.Handler, cleanup func(), _ error) {
|
||||
if !serveFlags.ModProxy {
|
||||
return nil, noop, nil // OK, proxy is disabled
|
||||
} else if serveFlags.HTTP == "" {
|
||||
return nil, nil, env.Usagef("you must set --http to enable --modproxy")
|
||||
}
|
||||
|
||||
modCachePath := filepath.Join(flags.CacheDir, "module")
|
||||
if err := os.MkdirAll(modCachePath, 0755); err != nil {
|
||||
return nil, nil, fmt.Errorf("create module cache: %w", err)
|
||||
}
|
||||
cacher := &modproxy.S3Cacher{
|
||||
Local: modCachePath,
|
||||
S3Client: s3c,
|
||||
KeyPrefix: path.Join(flags.KeyPrefix, "module"),
|
||||
MaxTasks: flags.S3Concurrency,
|
||||
Logf: vprintf,
|
||||
LogRequests: flags.DebugLog&debugModProxy != 0,
|
||||
}
|
||||
cleanup = func() { vprintf("close cacher (err=%v)", cacher.Close()) }
|
||||
proxy := &goproxy.Goproxy{
|
||||
Fetcher: &goproxy.GoFetcher{
|
||||
// As configured, the fetcher should never shell out to the go
|
||||
// tool. Specifically, because we set GOPROXY and do not set any
|
||||
// bypass via GONOPROXY, GOPRIVATE, etc., we will only attempt to
|
||||
// proxy for the specific server(s) listed in Env.
|
||||
GoBin: "/bin/false",
|
||||
Env: []string{"GOPROXY=https://proxy.golang.org"},
|
||||
},
|
||||
Cacher: cacher,
|
||||
ProxiedSumDBs: []string{"sum.golang.org"}, // default, see below
|
||||
}
|
||||
vprintf("enabling Go module proxy")
|
||||
if serveFlags.SumDB != "" {
|
||||
proxy.ProxiedSumDBs = strings.Split(serveFlags.SumDB, ",")
|
||||
vprintf("enabling sum DB proxy for %s", strings.Join(proxy.ProxiedSumDBs, ", "))
|
||||
}
|
||||
expvar.Publish("modcache", cacher.Metrics())
|
||||
return http.StripPrefix("/mod", proxy), cleanup, nil
|
||||
}
|
||||
|
||||
// initRevProxy initializes a reverse proxy if one is enabled. If not, it
|
||||
// returns nil, nil to indicate a proxy was not requested. Otherwise, it
|
||||
// returns a [http.Handler] to dispatch reverse proxy requests.
|
||||
//
|
||||
// The reverse proxy runs two collaborating HTTP servers:
|
||||
//
|
||||
// - The "inner" server is the proxy itself, which checks for cached values,
|
||||
// forwards client requests to the remote origin (if necessary), and
|
||||
// updates the cache with responses. The [revproxy.Server] is a lightweight
|
||||
// wrapper around [net/http/httputil.ReverseProxy].
|
||||
//
|
||||
// - The "outer" server is a bridge, that intercepts client requests. The
|
||||
// bridge forwards plain HTTP requests directly to the inner server. For
|
||||
// HTTPS CONNECT requests, the bridge hijacks the client connection and
|
||||
// terminates TLS using a locally-signed certificate, and forwards the
|
||||
// decrypted client requests to the inner caching proxy.
|
||||
//
|
||||
// The outer bridge is what receives requests routed by the main HTTP endpoint;
|
||||
// the inner server gets all its input via the bridge:
|
||||
//
|
||||
// +------------+ +--------+
|
||||
// client --[proxy-request]->|HTTP handler+--->| bridge +--CONNECT--+
|
||||
// +------------+ +---+----+ |
|
||||
// | |
|
||||
// HTTP v
|
||||
// +-------------+ | +---------------+
|
||||
// [response]<---| cache proxy |<------+--------+ terminate TLS |
|
||||
// +-------------+ +---------------+
|
||||
//
|
||||
// To the main HTTP listener, the bridge is an [http.Handler] that serves
|
||||
// requests routed to it. To the inner server, the bridge is a [net.Listener],
|
||||
// a source of client connections (with TLS terminated).
|
||||
func initRevProxy(env *command.Env, s3c *s3util.Client, g *taskgroup.Group) (http.Handler, error) {
|
||||
if serveFlags.RevProxy == "" {
|
||||
return nil, nil // OK, proxy is disabled
|
||||
} else if serveFlags.HTTP == "" {
|
||||
return nil, env.Usagef("you must set --http to enable --revproxy")
|
||||
}
|
||||
|
||||
revCachePath := filepath.Join(flags.CacheDir, "revproxy")
|
||||
if err := os.MkdirAll(revCachePath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create revproxy cache: %w", err)
|
||||
}
|
||||
hosts := strings.Split(serveFlags.RevProxy, ",")
|
||||
|
||||
// Issue a server certificate so we can proxy HTTPS requests.
|
||||
cert, err := initServerCert(env, hosts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := &revproxy.Server{
|
||||
Targets: hosts,
|
||||
Local: revCachePath,
|
||||
S3Client: s3c,
|
||||
KeyPrefix: path.Join(flags.KeyPrefix, "revproxy"),
|
||||
Logf: vprintf,
|
||||
LogRequests: flags.DebugLog&debugRevProxy != 0,
|
||||
}
|
||||
bridge := &proxyconn.Bridge{
|
||||
Addrs: hosts,
|
||||
Handler: proxy, // forward HTTP requests unencrypted to the proxy
|
||||
Logf: vprintf,
|
||||
|
||||
// Forward connections not matching Addrs directly to their targets.
|
||||
ForwardConnect: true,
|
||||
}
|
||||
expvar.Publish("proxyconn", bridge.Metrics())
|
||||
|
||||
// Run the proxy on its own separate server with TLS support. This server
|
||||
// does not listen on a real network; it receives connections forwarded by
|
||||
// the bridge internally from successful CONNECT requests.
|
||||
psrv := &http.Server{
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
|
||||
// Ordinarly HTTP proxy requests are delegated directly.
|
||||
Handler: proxy,
|
||||
}
|
||||
g.Go(func() error { return psrv.ServeTLS(bridge, "", "") })
|
||||
|
||||
g.Run(func() {
|
||||
<-env.Context().Done()
|
||||
vprintf("stopping proxy bridge")
|
||||
psrv.Shutdown(context.Background())
|
||||
})
|
||||
|
||||
expvar.Publish("revcache", proxy.Metrics())
|
||||
vprintf("enabling reverse proxy for %s", strings.Join(proxy.Targets, ", "))
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
// initServerCert creates a signed certificate advertising the specified host
|
||||
// names, for use in creating a TLS server.
|
||||
func initServerCert(env *command.Env, hosts []string) (tls.Certificate, error) {
|
||||
ca, err := tlsutil.NewSigningCert(24*time.Hour, &x509.Certificate{
|
||||
Subject: pkix.Name{Organization: []string{"Tailscale build automation"}},
|
||||
})
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("generate signing cert: %w", err)
|
||||
}
|
||||
if err := installSigningCert(env, ca); err != nil {
|
||||
vprintf("WARNING: %v", err)
|
||||
} else {
|
||||
vprintf("installed signing cert in system store")
|
||||
|
||||
// TODO(creachadair): We should probably clean up old expired certs.
|
||||
// This is OK for ephemeral build/CI workers, though.
|
||||
}
|
||||
|
||||
sc, err := tlsutil.NewServerCert(24*time.Hour, ca, &x509.Certificate{
|
||||
Subject: pkix.Name{Organization: []string{"Go cache plugin reverse proxy"}},
|
||||
DNSNames: hosts,
|
||||
})
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("generate server cert: %w", err)
|
||||
}
|
||||
|
||||
return sc.TLSCertificate()
|
||||
}
|
||||
|
||||
// makeHandler returns an HTTP handler that dispatches requests to debug
|
||||
// handlers or to the specified proxies, if they are defined.
|
||||
func makeHandler(modProxy, revProxy http.Handler) http.HandlerFunc {
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Host != "" && r.URL.Host == r.Host {
|
||||
// The caller wants us to proxy for them.
|
||||
if revProxy != nil {
|
||||
revProxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// We don't allow proxying in this configuration, bug off.
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
if strings.HasPrefix(path, "/debug/") {
|
||||
mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if modProxy != nil && r.Method == http.MethodGet && strings.HasPrefix(path, "/mod/") {
|
||||
modProxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// noop is a cleanup function that does nothing, used as a default.
|
||||
func noop() {}
|
||||
58
go-cache-plugin-src/go.mod
Normal file
58
go-cache-plugin-src/go.mod
Normal file
@@ -0,0 +1,58 @@
|
||||
module github.com/tailscale/go-cache-plugin
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
|
||||
github.com/creachadair/atomicfile v0.4.1
|
||||
github.com/creachadair/command v0.2.2
|
||||
github.com/creachadair/flax v0.0.5
|
||||
github.com/creachadair/gocache v0.0.0-20260418161958-99bafa82eafe
|
||||
github.com/creachadair/mds v0.27.1
|
||||
github.com/creachadair/mhttp v0.0.0-20260407161248-6866af4c556b
|
||||
github.com/creachadair/scheddle v0.0.0-20260418161627-87a4a0c853c4
|
||||
github.com/creachadair/taskgroup v0.14.2
|
||||
github.com/creachadair/tlsutil v0.0.0-20260218173745-49b0059fedaf
|
||||
github.com/goproxy/goproxy v0.21.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.40.0
|
||||
tailscale.com v1.96.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
||||
github.com/aws/smithy-go v1.25.0 // indirect
|
||||
github.com/creachadair/msync v0.8.1 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
)
|
||||
|
||||
retract (
|
||||
v0.0.19
|
||||
v0.0.16
|
||||
)
|
||||
|
||||
tool honnef.co/go/tools/cmd/staticcheck
|
||||
98
go-cache-plugin-src/go.sum
Normal file
98
go-cache-plugin-src/go.sum
Normal file
@@ -0,0 +1,98 @@
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
|
||||
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
||||
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/creachadair/atomicfile v0.4.1 h1:72SopAy00u42/iL0p3CZILv51oUEem8uDbxUfnzmsXU=
|
||||
github.com/creachadair/atomicfile v0.4.1/go.mod h1:+PsFcWa9SZuK+xnykzupXErqESId5eCGsuqFWkazMQI=
|
||||
github.com/creachadair/command v0.2.2 h1:4RGsUhqFf1imFC+vMWOOCiQdncThCdcdMJp0JNCjxxc=
|
||||
github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E=
|
||||
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
|
||||
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
|
||||
github.com/creachadair/gocache v0.0.0-20260418161958-99bafa82eafe h1:u136zrzzDUBqNQKPLiclaxza2spo+xtz06Mho09Vb/M=
|
||||
github.com/creachadair/gocache v0.0.0-20260418161958-99bafa82eafe/go.mod h1:wmtczGXKsjsSCKcMzEDDPofBdwXKfOM5fX0TMg1nsvU=
|
||||
github.com/creachadair/mds v0.27.1 h1:GlO1tPbrsaoafkF6mz7dFutkGXAtIfQLI450u0ypqwA=
|
||||
github.com/creachadair/mds v0.27.1/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
|
||||
github.com/creachadair/mhttp v0.0.0-20260407161248-6866af4c556b h1:bIvrALyE2ixkCvz9LADfOIuhkUMYWMBsvQ29xe70nI0=
|
||||
github.com/creachadair/mhttp v0.0.0-20260407161248-6866af4c556b/go.mod h1:eV+igunEfCBbgpM0gkDK/25/ukdeNYXH9PZuuEWZTDo=
|
||||
github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM=
|
||||
github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4=
|
||||
github.com/creachadair/scheddle v0.0.0-20260418161627-87a4a0c853c4 h1:uYu6k39F6Sz4Y5Zb+R2ZnhE1S5ehCw4lknwrt4bm7NI=
|
||||
github.com/creachadair/scheddle v0.0.0-20260418161627-87a4a0c853c4/go.mod h1:PE4owEX+08lTR1VNomiSFFh6DzkmmzlBgysEOq8zwdI=
|
||||
github.com/creachadair/taskgroup v0.14.2 h1:vPHw50VFJ98Eb+h3S1RYRejK3DE0xpGCXb2ZFtHfFeA=
|
||||
github.com/creachadair/taskgroup v0.14.2/go.mod h1:FV5K1BzHUCnTVc04O1WLO+iLmvjfOTnYLTcZaC9ZxbM=
|
||||
github.com/creachadair/tlsutil v0.0.0-20260218173745-49b0059fedaf h1:6ZqacBCoIdOSlwh03zr3TMqA5VyuFpzUS7E2yzfjq7w=
|
||||
github.com/creachadair/tlsutil v0.0.0-20260218173745-49b0059fedaf/go.mod h1:YUng4wUBh60nfswe0FwLeBOpoIsrW6ReXt9Zr9k55Sg=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/goproxy/goproxy v0.21.0 h1:S3JPCp/WLqLl7X40eo5FFiAbBZwD8S6TzGPBITwfHlo=
|
||||
github.com/goproxy/goproxy v0.21.0/go.mod h1:4j3iRV76B133PK4sOzVYWSBs/SsE5ovbZsdceoC5f+w=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||
267
go-cache-plugin-src/lib/gobuild/gobuild.go
Normal file
267
go-cache-plugin-src/lib/gobuild/gobuild.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package gobuild implements callbacks for a gocache.Server that store data
|
||||
// into an S3 bucket through a local directory.
|
||||
package gobuild
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/gocache"
|
||||
"github.com/creachadair/gocache/cachedir"
|
||||
"github.com/creachadair/taskgroup"
|
||||
"github.com/tailscale/go-cache-plugin/lib/s3util"
|
||||
)
|
||||
|
||||
// S3Cache implements callbacks for a gocache.Server using an S3 bucket for
|
||||
// backing store with a local directory for staging.
|
||||
//
|
||||
// # Remote Cache Layout
|
||||
//
|
||||
// Within the designated S3 bucket, keys are organized into two groups. Each
|
||||
// action is stored in a file named:
|
||||
//
|
||||
// [<prefix>/]action/<xx>/<action-id>
|
||||
//
|
||||
// Each output object is stored in a file named:
|
||||
//
|
||||
// [<prefix>/]output/<xx>/<object-id>
|
||||
//
|
||||
// The object and action IDs are encoded as lower-case hexadecimal strings,
|
||||
// with "<xx>" denoting the first two bytes of the ID to partition the space.
|
||||
//
|
||||
// The contents of each action file have the format:
|
||||
//
|
||||
// <output-id> <timestamp>
|
||||
//
|
||||
// where the object ID is hex encoded and the timestamp is Unix nanoseconds.
|
||||
// The object file contains just the binary data of the object.
|
||||
type S3Cache struct {
|
||||
// Local is the local cache directory where actions and objects are staged.
|
||||
// It must be non-nil. A local stage is required because the Go toolchain
|
||||
// needs direct access to read the files reported by the cache.
|
||||
// It is safe to use a tmpfs directory.
|
||||
Local *cachedir.Dir
|
||||
|
||||
// S3Client is the S3 client used to read and write cache entries to the
|
||||
// backing store. It must be non-nil.
|
||||
S3Client *s3util.Client
|
||||
|
||||
// KeyPrefix, if non-empty, is prepended to each key stored into S3, with an
|
||||
// intervening slash.
|
||||
KeyPrefix string
|
||||
|
||||
// MinUploadSize, if positive, defines a minimum object size in bytes below
|
||||
// which the cache will not write the object to S3.
|
||||
MinUploadSize int64
|
||||
|
||||
// UploadConcurrency, if positive, defines the maximum number of concurrent
|
||||
// tasks for writing cache entries to S3. If zero or negative, it uses
|
||||
// runtime.NumCPU.
|
||||
UploadConcurrency int
|
||||
|
||||
// Tracks tasks pushing cache writes to S3.
|
||||
initOnce sync.Once
|
||||
push *taskgroup.Group
|
||||
start func(taskgroup.Task)
|
||||
|
||||
getLocalHit expvar.Int // count of Get hits in the local cache
|
||||
getFaultHit expvar.Int // count of Get hits faulted in from S3
|
||||
getFaultMiss expvar.Int // count of Get faults that were misses
|
||||
putSkipSmall expvar.Int // count of "small" objects not written to S3
|
||||
putS3Found expvar.Int // count of objects not written to S3 because they were already present
|
||||
putS3Action expvar.Int // count of actions written to S3
|
||||
putS3Object expvar.Int // count of objects written to S3
|
||||
putS3Error expvar.Int // count of errors writing to S3
|
||||
}
|
||||
|
||||
func (s *S3Cache) init() {
|
||||
s.initOnce.Do(func() {
|
||||
s.push, s.start = taskgroup.New(nil).Limit(s.uploadConcurrency())
|
||||
})
|
||||
}
|
||||
|
||||
// Get implements the corresponding callback of the cache protocol.
|
||||
func (s *S3Cache) Get(ctx context.Context, actionID string) (outputID, diskPath string, _ error) {
|
||||
s.init()
|
||||
|
||||
objID, diskPath, err := s.Local.Get(ctx, actionID)
|
||||
if err == nil && objID != "" && diskPath != "" {
|
||||
s.getLocalHit.Add(1)
|
||||
return objID, diskPath, nil // cache hit, OK
|
||||
}
|
||||
|
||||
// Reaching here, either we got a cache miss or an error reading from local.
|
||||
// Try reading the action from S3.
|
||||
action, err := s.S3Client.GetData(ctx, s.actionKey(actionID))
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
s.getFaultMiss.Add(1)
|
||||
return "", "", nil // cache miss, OK
|
||||
}
|
||||
return "", "", fmt.Errorf("[s3] read action %s: %w", actionID, err)
|
||||
}
|
||||
|
||||
// We got an action hit remotely, try to update the local copy.
|
||||
outputID, mtime, err := parseAction(action)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
object, size, err := s.S3Client.Get(ctx, s.outputKey(outputID))
|
||||
if err != nil {
|
||||
// At this point we know the action exists, so if we can't read the
|
||||
// object report it as an error rather than a cache miss.
|
||||
return "", "", fmt.Errorf("[s3] read object %s: %w", outputID, err)
|
||||
}
|
||||
defer object.Close()
|
||||
s.getFaultHit.Add(1)
|
||||
|
||||
// Now we should have the body; poke it into the local cache. Preserve the
|
||||
// modification timestamp recorded with the original action.
|
||||
diskPath, err = s.Local.Put(ctx, gocache.Object{
|
||||
ActionID: actionID,
|
||||
OutputID: outputID,
|
||||
Size: size,
|
||||
Body: object,
|
||||
ModTime: mtime,
|
||||
})
|
||||
return outputID, diskPath, err
|
||||
}
|
||||
|
||||
// Put implements the corresponding callback of the cache protocol.
|
||||
func (s *S3Cache) Put(ctx context.Context, obj gocache.Object) (diskPath string, _ error) {
|
||||
s.init()
|
||||
|
||||
// Compute an etag so we can do a conditional put on the object data.
|
||||
// We do not rely on it as a secure checksum. The toolchain verifies the
|
||||
// content address against the bits we actually store.
|
||||
etr := s3util.NewETagReader(obj.Body)
|
||||
obj.Body = etr
|
||||
|
||||
diskPath, err := s.Local.Put(ctx, obj)
|
||||
if err != nil {
|
||||
return "", err // don't bother trying to forward it to the remote
|
||||
}
|
||||
if obj.Size < s.MinUploadSize {
|
||||
s.putSkipSmall.Add(1)
|
||||
return diskPath, nil // don't bother uploading this, it's too small
|
||||
}
|
||||
|
||||
// Try to push the record to S3 in the background.
|
||||
s.start(func() error {
|
||||
// Override the context with a separate timeout in case S3 is farkakte.
|
||||
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Stage 1: Maybe write the object. Do this before writing the action
|
||||
// record so we are less likely to get a spurious miss later.
|
||||
mtime, err := s.maybePutObject(sctx, obj.OutputID, diskPath, etr.ETag())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Stage 2: Write the action record.
|
||||
if err := s.S3Client.Put(ctx, s.actionKey(obj.ActionID),
|
||||
strings.NewReader(fmt.Sprintf("%s %d", obj.OutputID, mtime.UnixNano()))); err != nil {
|
||||
gocache.Logf(ctx, "write action %s: %v", obj.ActionID, err)
|
||||
return err
|
||||
}
|
||||
s.putS3Action.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
return diskPath, nil
|
||||
}
|
||||
|
||||
// Close implements the corresponding callback of the cache protocol.
|
||||
func (s *S3Cache) Close(ctx context.Context) error {
|
||||
if s.push != nil {
|
||||
gocache.Logf(ctx, "waiting for uploads...")
|
||||
wstart := time.Now()
|
||||
s.push.Wait()
|
||||
gocache.Logf(ctx, "uploads complete (%v elapsed)", time.Since(wstart).Round(10*time.Microsecond))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMetrics implements the corresponding server callback.
|
||||
func (s *S3Cache) SetMetrics(_ context.Context, m *expvar.Map) {
|
||||
m.Set("get_local_hit", &s.getLocalHit)
|
||||
m.Set("get_fault_hit", &s.getFaultHit)
|
||||
m.Set("get_fault_miss", &s.getFaultMiss)
|
||||
m.Set("put_skip_small", &s.putSkipSmall)
|
||||
m.Set("put_s3_found", &s.putS3Found)
|
||||
m.Set("put_s3_action", &s.putS3Action)
|
||||
m.Set("put_s3_object", &s.putS3Object)
|
||||
m.Set("put_s3_error", &s.putS3Error)
|
||||
}
|
||||
|
||||
// maybePutObject writes the specified object contents to S3 if there is not
|
||||
// already a matching key with the same etag. It returns the modified time of
|
||||
// the object file, whether or not it was sent to S3.
|
||||
func (s *S3Cache) maybePutObject(ctx context.Context, outputID, diskPath, etag string) (time.Time, error) {
|
||||
f, err := os.Open(diskPath)
|
||||
if err != nil {
|
||||
gocache.Logf(ctx, "[s3] open local object %s: %v", outputID, err)
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
written, err := s.S3Client.PutCond(ctx, s.outputKey(outputID), etag, f)
|
||||
if err != nil {
|
||||
s.putS3Error.Add(1)
|
||||
gocache.Logf(ctx, "[s3] put object %s: %v", outputID, err)
|
||||
return fi.ModTime(), err
|
||||
}
|
||||
if written {
|
||||
s.putS3Found.Add(1)
|
||||
return fi.ModTime(), nil // already present and matching
|
||||
}
|
||||
s.putS3Object.Add(1)
|
||||
return fi.ModTime(), nil
|
||||
}
|
||||
|
||||
// makeKey assembles a complete key from the specified parts, including the key
|
||||
// prefix if one is defined.
|
||||
func (s *S3Cache) makeKey(parts ...string) string {
|
||||
return path.Join(s.KeyPrefix, path.Join(parts...))
|
||||
}
|
||||
|
||||
func (s *S3Cache) actionKey(id string) string { return s.makeKey("action", id[:2], id) }
|
||||
func (s *S3Cache) outputKey(id string) string { return s.makeKey("output", id[:2], id) }
|
||||
|
||||
func (s *S3Cache) uploadConcurrency() int {
|
||||
if s.UploadConcurrency <= 0 {
|
||||
return runtime.NumCPU()
|
||||
}
|
||||
return s.UploadConcurrency
|
||||
}
|
||||
|
||||
func parseAction(data []byte) (outputID string, mtime time.Time, _ error) {
|
||||
fs := strings.Fields(string(data))
|
||||
if len(fs) != 2 {
|
||||
return "", time.Time{}, errors.New("invalid action record")
|
||||
}
|
||||
ts, err := strconv.ParseInt(fs[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
return fs[0], time.Unix(ts/1e9, ts%1e9), nil
|
||||
}
|
||||
323
go-cache-plugin-src/lib/modproxy/modproxy.go
Normal file
323
go-cache-plugin-src/lib/modproxy/modproxy.go
Normal file
@@ -0,0 +1,323 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package modproxy implements components of a Go module proxy that caches
|
||||
// files locally on disk, backed by objects in an S3 bucket.
|
||||
package modproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/atomicfile"
|
||||
"github.com/creachadair/taskgroup"
|
||||
"github.com/goproxy/goproxy"
|
||||
"github.com/tailscale/go-cache-plugin/lib/s3util"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
var _ goproxy.Cacher = (*S3Cacher)(nil)
|
||||
|
||||
// S3Cacher implements the [github.com/goproxy/goproxy.Cacher] interface using
|
||||
// a local disk cache backed by an S3 bucket.
|
||||
//
|
||||
// # Cache Layout
|
||||
//
|
||||
// Module cache files are stored under a SHA256 digest of the filename
|
||||
// presented to the cache, encoded as hex and partitioned by the first two
|
||||
// bytes of the digest:
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// SHA256("fizzlepug") → 160db4d719252162c87a9169e26deda33d2340770d0d540fd4c580c55008b2d6
|
||||
// <cache-dir>/module/16/160db4d719252162c87a9169e26deda33d2340770d0d540fd4c580c55008b2d6
|
||||
//
|
||||
// When files are stored in S3, the same naming convention is used, but with
|
||||
// the specified key prefix instead:
|
||||
//
|
||||
// <key-prefix>/module/16/0db4d719252162c87a9169e26deda33d2340770d0d540fd4c580c55008b2d6
|
||||
type S3Cacher struct {
|
||||
// Local is the path of a local cache directory where modules are cached.
|
||||
// It must be non-empty.
|
||||
Local string
|
||||
|
||||
// S3Client is the S3 client used to read and write cache entries to the
|
||||
// backing store. It must be non-nil.
|
||||
S3Client *s3util.Client
|
||||
|
||||
// KeyPrefix, if non-empty, is prepended to each key stored into S3, with an
|
||||
// intervening slash.
|
||||
KeyPrefix string
|
||||
|
||||
// MaxTasks, if positive, limits the number of concurrent tasks that may be
|
||||
// interacting with S3. If zero or negative, the default is
|
||||
// [runtime.NumCPU].
|
||||
MaxTasks int
|
||||
|
||||
// Logf, if non-nil, is used to write log messages. If nil, logs are
|
||||
// discarded.
|
||||
Logf func(string, ...any)
|
||||
|
||||
// LogRequests, if true, enables detailed (but noisy) debug logging of all
|
||||
// requests handled by the cache. Logs are written to Logf.
|
||||
//
|
||||
// Each result is presented in the format:
|
||||
//
|
||||
// B <op> "<name>" (<digest>)
|
||||
// E <op> "<name>", err=<error>, <time> elapsed
|
||||
//
|
||||
// Where the operations are "GET" and "PUT". The "B" line is when the
|
||||
// operation began, and "E" when it ended. When a GET operation successfully
|
||||
// faults in a result from S3, the log is:
|
||||
//
|
||||
// F GET "<name>" hit (<digest>)
|
||||
//
|
||||
// When a PUT operation finishes writing a value behind to S3, the log is:
|
||||
//
|
||||
// W PUT "<name>", err=<error>, <time> elapsed
|
||||
//
|
||||
LogRequests bool
|
||||
|
||||
// Tracks tasks interacting with S3 in the background.
|
||||
initOnce sync.Once
|
||||
tasks *taskgroup.Group
|
||||
start func(taskgroup.Task)
|
||||
sema *semaphore.Weighted
|
||||
|
||||
pathError expvar.Int // errors constructing file paths
|
||||
getRequest expvar.Int // total number of Get requests
|
||||
getLocalHit expvar.Int // get: hit in local directory
|
||||
getLocalMiss expvar.Int // get: miss in local directory
|
||||
getFaultHit expvar.Int // get: hit in S3
|
||||
getFaultMiss expvar.Int // get: miss in S3
|
||||
getLocalError expvar.Int // get: error reading the local directory
|
||||
getFaultError expvar.Int // get: error reading from S3
|
||||
getLocalBytes expvar.Int // get: total bytes fetched from the local directory
|
||||
getS3Bytes expvar.Int // get: total bytes fetched from S3
|
||||
putRequest expvar.Int // total number of Put requests
|
||||
putLocalHit expvar.Int // put: put of object already stored locally
|
||||
putLocalError expvar.Int // put: error writing the local directory
|
||||
putS3Error expvar.Int // put: error writing to S3
|
||||
putLocalBytes expvar.Int // put: total bytes written to the local directory
|
||||
putS3Bytes expvar.Int // put: total bytes written to S3
|
||||
}
|
||||
|
||||
func (c *S3Cacher) init() {
|
||||
c.initOnce.Do(func() {
|
||||
nt := c.MaxTasks
|
||||
if nt <= 0 {
|
||||
nt = runtime.NumCPU()
|
||||
}
|
||||
c.tasks, c.start = taskgroup.New(nil).Limit(nt)
|
||||
c.sema = semaphore.NewWeighted(int64(nt))
|
||||
})
|
||||
}
|
||||
|
||||
// Get implements a method of the goproxy.Cacher interface. It reports cache
|
||||
// hits out of the local directory if available, or faults in from S3.
|
||||
func (c *S3Cacher) Get(ctx context.Context, name string) (_ io.ReadCloser, oerr error) {
|
||||
c.init()
|
||||
c.getRequest.Add(1)
|
||||
start := time.Now()
|
||||
hash, path, err := c.makePath(name)
|
||||
|
||||
c.vlogf("mc B GET %q (%s)", name, hash)
|
||||
defer func() { c.vlogf("mc E GET %q, err=%v, %v elapsed", name, oerr, time.Since(start)) }()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check whether the file already exists locally.
|
||||
if rc, size, err := openReader(path); err == nil {
|
||||
c.getLocalHit.Add(1)
|
||||
c.getLocalBytes.Add(size)
|
||||
return rc, nil
|
||||
} else if errors.Is(err, os.ErrNotExist) {
|
||||
c.getLocalMiss.Add(1)
|
||||
} else {
|
||||
c.getLocalError.Add(1)
|
||||
c.logf("get %q local: %v (treating as miss)", name, err)
|
||||
}
|
||||
|
||||
// Local cache miss, fault in from S3.
|
||||
if err := c.sema.Acquire(ctx, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.sema.Release(1)
|
||||
|
||||
obj, _, err := c.S3Client.Get(ctx, c.makeKey(hash))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
c.getFaultMiss.Add(1)
|
||||
return nil, err
|
||||
} else if err != nil {
|
||||
c.getFaultError.Add(1)
|
||||
return nil, err
|
||||
}
|
||||
defer obj.Close()
|
||||
c.getFaultHit.Add(1)
|
||||
c.vlogf("mc F GET %q hit (%s)", name, hash)
|
||||
|
||||
if _, err := c.putLocal(ctx, name, path, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc, _, err := openReader(path)
|
||||
return rc, err
|
||||
}
|
||||
|
||||
// putLocal reports whether the specified path already exists in the local
|
||||
// cache, and if not, writes data atomically into the path.
|
||||
func (c *S3Cacher) putLocal(ctx context.Context, name, path string, data io.Reader) (bool, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
nw, err := atomicfile.WriteAll(path, data, 0644)
|
||||
c.putLocalBytes.Add(nw)
|
||||
if err != nil {
|
||||
c.putLocalError.Add(1)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Put implements a method of the goproxy.Cacher interface. It stores data into
|
||||
// the local directory and then writes it back to S3 in the background.
|
||||
func (c *S3Cacher) Put(ctx context.Context, name string, data io.ReadSeeker) (oerr error) {
|
||||
c.init()
|
||||
c.putRequest.Add(1)
|
||||
start := time.Now()
|
||||
hash, path, err := c.makePath(name)
|
||||
|
||||
c.vlogf("mc B PUT %q (%s)", name, hash)
|
||||
defer func() { c.vlogf("mc E PUT %q, err=%v, %v elapsed", name, oerr, time.Since(start)) }()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := c.putLocal(ctx, name, path, data); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
c.putLocalHit.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to push the object to S3 in the background.
|
||||
f, size, err := openFileSize(path)
|
||||
if err != nil {
|
||||
c.putLocalError.Add(1)
|
||||
return err
|
||||
}
|
||||
c.start(func() error {
|
||||
defer f.Close()
|
||||
start := time.Now()
|
||||
|
||||
// Override the context with a separate timeout in case S3 is farkakte.
|
||||
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := c.S3Client.Put(sctx, c.makeKey(hash), f); err != nil {
|
||||
c.putS3Error.Add(1)
|
||||
c.logf("[s3] put %q failed: %v", name, err)
|
||||
} else {
|
||||
c.putS3Bytes.Add(size)
|
||||
}
|
||||
c.vlogf("mc W PUT %q, err=%v %v elapsed", name, err, time.Since(start))
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close waits until all background updates are complete.
|
||||
func (c *S3Cacher) Close() error {
|
||||
c.init()
|
||||
return c.tasks.Wait()
|
||||
}
|
||||
|
||||
// Metrics returns a map of cacher metrics. The caller is responsible for
|
||||
// publishing these metrics.
|
||||
func (c *S3Cacher) Metrics() *expvar.Map {
|
||||
m := new(expvar.Map)
|
||||
m.Set("path_error", &c.pathError)
|
||||
m.Set("get_request", &c.getRequest)
|
||||
m.Set("get_local_hit", &c.getLocalHit)
|
||||
m.Set("get_local_miss", &c.getLocalMiss)
|
||||
m.Set("get_fault_hit", &c.getFaultHit)
|
||||
m.Set("get_fault_miss", &c.getFaultMiss)
|
||||
m.Set("get_local_error", &c.getLocalError)
|
||||
m.Set("get_local_bytes", &c.getLocalBytes)
|
||||
m.Set("get_s3_bytes", &c.getS3Bytes)
|
||||
m.Set("put_request", &c.putRequest)
|
||||
m.Set("put_local_hit", &c.putLocalHit)
|
||||
m.Set("put_local_error", &c.putLocalError)
|
||||
m.Set("put_s3_error", &c.putS3Error)
|
||||
m.Set("put_local_bytes", &c.putLocalBytes)
|
||||
m.Set("put_s3_bytes", &c.putS3Bytes)
|
||||
return m
|
||||
}
|
||||
|
||||
func hashName(name string) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(name)))
|
||||
}
|
||||
|
||||
// makeKey assembles a complete S3 key from the specified parts, including the
|
||||
// key prefix if one is defined.
|
||||
func (c *S3Cacher) makeKey(hash string) string {
|
||||
return path.Join(c.KeyPrefix, hash[:2], hash)
|
||||
}
|
||||
|
||||
// makePath assembles a complete local cache path for the given name, creating
|
||||
// the enclosing directory if needed.
|
||||
func (c *S3Cacher) makePath(name string) (hash, path string, err error) {
|
||||
hash = hashName(name)
|
||||
path = filepath.Join(c.Local, hash[:2], hash)
|
||||
err = os.MkdirAll(filepath.Dir(path), 0755)
|
||||
if err != nil {
|
||||
c.pathError.Add(1)
|
||||
}
|
||||
return hash, path, err
|
||||
}
|
||||
|
||||
func (c *S3Cacher) logf(msg string, args ...any) {
|
||||
if c.Logf != nil {
|
||||
c.Logf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *S3Cacher) vlogf(msg string, args ...any) {
|
||||
if c.LogRequests {
|
||||
c.logf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func openReader(path string) (_ io.ReadCloser, size int64, _ error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), int64(len(data)), nil
|
||||
}
|
||||
|
||||
func openFileSize(path string) (io.ReadCloser, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, fi.Size(), nil
|
||||
}
|
||||
159
go-cache-plugin-src/lib/revproxy/cache.go
Normal file
159
go-cache-plugin-src/lib/revproxy/cache.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package revproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/atomicfile"
|
||||
"github.com/creachadair/scheddle"
|
||||
"github.com/creachadair/taskgroup"
|
||||
)
|
||||
|
||||
// cacheLoadLocal reads cached headers and body from the local cache.
|
||||
func (s *Server) cacheLoadLocal(hash string) ([]byte, http.Header, error) {
|
||||
data, err := os.ReadFile(s.makePath(hash))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return parseCacheObject(data)
|
||||
}
|
||||
|
||||
// cacheStoreLocal writes the contents of body to the local cache.
|
||||
//
|
||||
// The file format is a plain-text section at the top recording a subset of the
|
||||
// response headers, followed by "\n\n", followed by the response body.
|
||||
func (s *Server) cacheStoreLocal(hash string, hdr http.Header, body []byte) error {
|
||||
path := s.makePath(hash)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicfile.Tx(s.makePath(hash), 0644, func(f io.Writer) error {
|
||||
return writeCacheObject(f, hdr, body)
|
||||
})
|
||||
}
|
||||
|
||||
// cacheLoadS3 reads cached headers and body from the remote S3 cache.
|
||||
func (s *Server) cacheLoadS3(ctx context.Context, hash string) ([]byte, http.Header, error) {
|
||||
data, err := s.S3Client.GetData(ctx, s.makeKey(hash))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return parseCacheObject(data)
|
||||
}
|
||||
|
||||
// cacheStoreS3 returns a task that writes the contents of body to the remote
|
||||
// S3 cache.
|
||||
func (s *Server) cacheStoreS3(hash string, hdr http.Header, body []byte) taskgroup.Task {
|
||||
var buf bytes.Buffer
|
||||
writeCacheObject(&buf, hdr, body)
|
||||
nb := buf.Len()
|
||||
return func() error {
|
||||
sctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := s.S3Client.Put(sctx, s.makeKey(hash), &buf); err != nil {
|
||||
s.logf("[s3] put %q failed: %v", hash, err)
|
||||
s.rspPushError.Add(1)
|
||||
} else {
|
||||
s.rspPush.Add(1)
|
||||
s.rspPushBytes.Add(int64(nb))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cacheLoadMemory reads cached headers and body from the memory cache.
|
||||
func (s *Server) cacheLoadMemory(hash string) ([]byte, http.Header, error) {
|
||||
e, ok := s.mcache.Get(hash)
|
||||
if !ok {
|
||||
return nil, nil, fs.ErrNotExist
|
||||
}
|
||||
return e.body, e.header, nil
|
||||
}
|
||||
|
||||
// cacheStoreMemory writes the contents of body to the memory cache.
|
||||
func (s *Server) cacheStoreMemory(hash string, maxAge time.Duration, hdr http.Header, body []byte) {
|
||||
s.mcache.Put(hash, memCacheEntry{
|
||||
header: trimCacheHeader(hdr),
|
||||
body: body,
|
||||
})
|
||||
s.expire.After(maxAge, scheddle.Run(func() {
|
||||
s.mcache.Remove(hash)
|
||||
}))
|
||||
}
|
||||
|
||||
var keepHeader = []string{
|
||||
"Cache-Control", "Content-Type", "Date", "Etag",
|
||||
}
|
||||
|
||||
func trimCacheHeader(h http.Header) http.Header {
|
||||
out := make(http.Header)
|
||||
for _, name := range keepHeader {
|
||||
if v := h.Get(name); v != "" {
|
||||
out.Set(name, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseCacheDbject parses cached object data to extract the body and headers.
|
||||
func parseCacheObject(data []byte) ([]byte, http.Header, error) {
|
||||
hdr, rest, ok := bytes.Cut(data, []byte("\n\n"))
|
||||
if !ok {
|
||||
return nil, nil, errors.New("invalid cache object: missing header")
|
||||
}
|
||||
h := make(http.Header)
|
||||
for _, line := range strings.Split(string(hdr), "\n") {
|
||||
name, value, ok := strings.Cut(line, ": ")
|
||||
if ok {
|
||||
h.Add(name, value)
|
||||
}
|
||||
}
|
||||
return rest, h, nil
|
||||
}
|
||||
|
||||
// writeCacheObject writes the specified response data into a cache object at w.
|
||||
func writeCacheObject(w io.Writer, h http.Header, body []byte) error {
|
||||
hprintf(w, h, "Content-Type", "application/octet-stream")
|
||||
hprintf(w, h, "Date", "")
|
||||
hprintf(w, h, "Etag", "")
|
||||
fmt.Fprint(w, "\n")
|
||||
_, err := w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func hprintf(w io.Writer, h http.Header, name, fallback string) {
|
||||
if v := h.Get(name); v != "" {
|
||||
fmt.Fprintf(w, "%s: %s\n", name, v)
|
||||
} else if fallback != "" {
|
||||
fmt.Fprintf(w, "%s: %s\n", name, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// setXCacheInfo adds cache-specific headers to h.
|
||||
func setXCacheInfo(h http.Header, result, hash string) {
|
||||
h.Set("X-Cache", result)
|
||||
if hash != "" {
|
||||
h.Set("X-Cache-Id", hash[:12])
|
||||
}
|
||||
}
|
||||
|
||||
// memCacheEntry is the format of entries in the memory cache.
|
||||
type memCacheEntry struct {
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func entrySize(e memCacheEntry) int64 { return int64(len(e.body)) }
|
||||
407
go-cache-plugin-src/lib/revproxy/revproxy.go
Normal file
407
go-cache-plugin-src/lib/revproxy/revproxy.go
Normal file
@@ -0,0 +1,407 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package revproxy implements a minimal HTTP reverse proxy that caches files
|
||||
// locally on disk, backed by objects in an S3 bucket.
|
||||
//
|
||||
// # Limitations
|
||||
//
|
||||
// By default, only objects marked "immutable" by the target server are
|
||||
// eligible to be cached. Volatile objects that specify a max-age are also
|
||||
// cached in-memory, but are not persisted on disk or in S3. If we think it's
|
||||
// worthwhile we can spend some time to add more elaborate cache pruning, but
|
||||
// for now we're doing the simpler thing.
|
||||
package revproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/mds/cache"
|
||||
"github.com/creachadair/mds/mapset"
|
||||
"github.com/creachadair/scheddle"
|
||||
"github.com/creachadair/taskgroup"
|
||||
"github.com/tailscale/go-cache-plugin/lib/s3util"
|
||||
)
|
||||
|
||||
// Server is a caching reverse proxy server that caches successful responses to
|
||||
// GET requests for certain designated domains.
|
||||
//
|
||||
// The host field of the request URL must match one of the configured targets.
|
||||
// If not, the request is rejected with HTTP 502 (Bad Gateway). Otherwise, the
|
||||
// request is forwarded. A successful response will be cached if the server's
|
||||
// Cache-Control does not include "no-store", and does include "immutable".
|
||||
//
|
||||
// In addition, a successful response that is not immutable and specifies a
|
||||
// max-age will be cached temporarily in-memory.
|
||||
//
|
||||
// # Cache Format
|
||||
//
|
||||
// A cached response is a file with a header section and the body, separated by
|
||||
// a blank line. Only a subset of response headers are saved.
|
||||
//
|
||||
// # Cache Responses
|
||||
//
|
||||
// For requests handled by the proxy, the response includes an "X-Cache" header
|
||||
// indicating how the response was obtained:
|
||||
//
|
||||
// - "hit, memory": The response was served out of the memory cache.
|
||||
// - "hit, local": The response was served out of the local cache.
|
||||
// - "hit, remote": The response was faulted in from S3.
|
||||
// - "fetch, cached": The response was forwarded to the target and cached.
|
||||
// - "fetch, uncached": The response was forwarded to the target and not cached.
|
||||
//
|
||||
// For results intersecting with the cache, it also reports a X-Cache-Id giving
|
||||
// the storage key of the cache object.
|
||||
type Server struct {
|
||||
// Targets is the list of hosts for which the proxy should forward requests.
|
||||
// Host names should be fully-qualified ("host.example.com").
|
||||
Targets []string
|
||||
|
||||
// Local is the path of a local cache directory where responses are cached.
|
||||
// It must be non-empty.
|
||||
Local string
|
||||
|
||||
// S3Client is the S3 client used to read and write cache entries to the
|
||||
// backing store. It must be non-nil
|
||||
S3Client *s3util.Client
|
||||
|
||||
// KeyPrefix, if non-empty, is prepended to each key stored into S3, with an
|
||||
// intervening slash.
|
||||
KeyPrefix string
|
||||
|
||||
// Logf, if non-nil, is used to write log messages. If nil, logs are
|
||||
// discarded.
|
||||
Logf func(string, ...any)
|
||||
|
||||
// LogRequests, if true, enables detailed (but noisy) debug logging of all
|
||||
// requests handled by the reverse proxy. Logs are written to Logf.
|
||||
//
|
||||
// Each request is presented in the format:
|
||||
//
|
||||
// B U:"<url>" H:<digest> C:<bool>
|
||||
// E H:<digest> <disposition> B:<bytes> (<time> elapsed)
|
||||
// - H:<digest> miss
|
||||
//
|
||||
// The "B" line is when the request began, and "E" when it was finished.
|
||||
// The abbreviated fields are:
|
||||
//
|
||||
// U: -- request URL
|
||||
// H: -- request URL digest (cache key)
|
||||
// C: -- whether the request is cacheable (true/false)
|
||||
// B: -- body size in bytes (for hits)
|
||||
//
|
||||
// The dispositions of a request are:
|
||||
//
|
||||
// hit mem -- cache hit in memory (volatile)
|
||||
// hit disk -- cache hit in local disk
|
||||
// hit S3 -- cache hit in S3 (faulted to disk)
|
||||
// fetch -- fetched from the origin server
|
||||
//
|
||||
// On fetches, the "RC" tag indicates whether the response is cacheable,
|
||||
// with "no" meaning it was not cached at all, "mem" meaning it was cached
|
||||
// as a short-lived volatile response in memory, and "yes" meaning it was
|
||||
// cached on disk (and S3).
|
||||
LogRequests bool
|
||||
|
||||
initOnce sync.Once
|
||||
tasks *taskgroup.Group
|
||||
start func(taskgroup.Task)
|
||||
mcache *cache.Cache[string, memCacheEntry] // short-lived mutable objects
|
||||
expire *scheddle.Queue // cache expirations
|
||||
|
||||
reqReceived expvar.Int // total requests received
|
||||
reqMemoryHit expvar.Int // hit in memory cache (volatile)
|
||||
reqLocalHit expvar.Int // hit in local cache
|
||||
reqLocalMiss expvar.Int // miss in local cache
|
||||
reqFaultHit expvar.Int // hit in remote (S3) cache
|
||||
reqFaultMiss expvar.Int // miss in remote (S3) cache
|
||||
reqForward expvar.Int // request forwarded directly to upstream
|
||||
rspSave expvar.Int // successful response saved in local cache
|
||||
rspSaveMem expvar.Int // response saved in memory cache
|
||||
rspSaveError expvar.Int // error saving to local cache
|
||||
rspSaveBytes expvar.Int // bytes written to local cache
|
||||
rspPush expvar.Int // successful response saved in S3
|
||||
rspPushError expvar.Int // error saving to S3
|
||||
rspPushBytes expvar.Int // bytes written to S3
|
||||
rspNotCached expvar.Int // response not cached anywhere
|
||||
}
|
||||
|
||||
func (s *Server) init() {
|
||||
s.initOnce.Do(func() {
|
||||
nt := runtime.NumCPU()
|
||||
s.tasks, s.start = taskgroup.New(nil).Limit(nt)
|
||||
s.mcache = cache.New(cache.LRU[string, memCacheEntry]().
|
||||
WithLimit(10 << 20).
|
||||
WithSizeFunc(entrySize),
|
||||
)
|
||||
s.expire = scheddle.NewQueue(nil)
|
||||
})
|
||||
}
|
||||
|
||||
// Metrics returns a map of cache server metrics for s. The caller is
|
||||
// responsible to publish these metrics as desired.
|
||||
func (s *Server) Metrics() *expvar.Map {
|
||||
m := new(expvar.Map)
|
||||
m.Set("req_received", &s.reqReceived)
|
||||
m.Set("req_memory_hit", &s.reqMemoryHit)
|
||||
m.Set("req_local_hit", &s.reqLocalHit)
|
||||
m.Set("req_local_miss", &s.reqLocalMiss)
|
||||
m.Set("req_fault_hit", &s.reqFaultHit)
|
||||
m.Set("req_fault_miss", &s.reqFaultMiss)
|
||||
m.Set("req_forward", &s.reqForward)
|
||||
m.Set("rsp_save", &s.rspSave)
|
||||
m.Set("rsp_save_memory", &s.rspSaveMem)
|
||||
m.Set("rsp_save_error", &s.rspSaveError)
|
||||
m.Set("rsp_save_bytes", &s.rspSaveBytes)
|
||||
m.Set("rsp_push", &s.rspPush)
|
||||
m.Set("rsp_push_error", &s.rspPushError)
|
||||
m.Set("rsp_push_bytes", &s.rspPushBytes)
|
||||
m.Set("rsp_not_cached", &s.rspNotCached)
|
||||
return m
|
||||
}
|
||||
|
||||
// ServeHTTP implements the [http.Handler] interface for the proxy.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.init()
|
||||
s.reqReceived.Add(1)
|
||||
|
||||
// Check whether this request is to a target we are permitted to proxy for.
|
||||
if !hostMatchesTarget(r.Host, s.Targets) {
|
||||
s.logf("reject proxy request for non-target %q", r.Host)
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
hash := hashRequestURL(r.URL)
|
||||
canCache := s.canCacheRequest(r)
|
||||
s.vlogf("rp B U:%q H:%s C:%v", r.URL, hash, canCache)
|
||||
start := time.Now()
|
||||
if canCache {
|
||||
// Check for a hit on this object in the memory cache.
|
||||
if data, hdr, err := s.cacheLoadMemory(hash); err == nil {
|
||||
s.reqMemoryHit.Add(1)
|
||||
setXCacheInfo(hdr, "hit, memory", hash)
|
||||
writeCachedResponse(w, hdr, data)
|
||||
s.vlogf("rp E H:%s hit mem B:%d (%v elapsed)", hash, len(data), time.Since(start))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for a hit on this object in the local cache.
|
||||
if data, hdr, err := s.cacheLoadLocal(hash); err == nil {
|
||||
s.reqLocalHit.Add(1)
|
||||
setXCacheInfo(hdr, "hit, local", hash)
|
||||
writeCachedResponse(w, hdr, data)
|
||||
s.vlogf("rp E H:%s hit disk B:%d (%v elapsed)", hash, len(data), time.Since(start))
|
||||
return
|
||||
}
|
||||
s.reqLocalMiss.Add(1)
|
||||
|
||||
// Fault in from S3.
|
||||
if data, hdr, err := s.cacheLoadS3(r.Context(), hash); err == nil {
|
||||
s.reqFaultHit.Add(1)
|
||||
if err := s.cacheStoreLocal(hash, hdr, data); err != nil {
|
||||
s.logf("update %q local: %v", hash, err)
|
||||
}
|
||||
setXCacheInfo(hdr, "hit, remote", hash)
|
||||
writeCachedResponse(w, hdr, data)
|
||||
s.vlogf("rp E H:%s hit S3 B:%d (%v elapsed)", hash, len(data), time.Since(start))
|
||||
return
|
||||
}
|
||||
s.reqFaultMiss.Add(1)
|
||||
s.vlogf("rp - H:%s miss", hash)
|
||||
}
|
||||
|
||||
// Reaching here, the object is not already cached locally so we have to
|
||||
// talk to the backend to get it. We need to do this whether or not it is
|
||||
// cacheable. Note we handle each request with its own proxy instance, so
|
||||
// that we can handle each response in context of this request.
|
||||
s.reqForward.Add(1)
|
||||
proxy := &httputil.ReverseProxy{Rewrite: s.rewriteRequest}
|
||||
updateCache := func() {}
|
||||
if canCache {
|
||||
proxy.ModifyResponse = func(rsp *http.Response) error {
|
||||
maxAge, isVolatile := s.canMemoryCache(rsp)
|
||||
canCacheResponse := s.canCacheResponse(rsp)
|
||||
if !canCacheResponse && !isVolatile {
|
||||
// A response we cannot cache at all.
|
||||
setXCacheInfo(rsp.Header, "fetch, uncached", "")
|
||||
s.rspNotCached.Add(1)
|
||||
s.vlogf("rp E H:%s fetch RC:no (%v elapsed)", hash, time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read out the whole response body so we can update the cache, and
|
||||
// replace the response reader so we can copy it back to the caller.
|
||||
var buf bytes.Buffer
|
||||
rsp.Body = copyReader{
|
||||
Reader: io.TeeReader(rsp.Body, &buf),
|
||||
Closer: rsp.Body,
|
||||
}
|
||||
if !canCacheResponse && isVolatile {
|
||||
// A volatile response we can cache temporarily.
|
||||
setXCacheInfo(rsp.Header, "fetch, cached, volatile", hash)
|
||||
updateCache = func() {
|
||||
body := buf.Bytes()
|
||||
s.cacheStoreMemory(hash, maxAge, rsp.Header, body)
|
||||
s.rspSaveMem.Add(1)
|
||||
|
||||
// N.B. Don't persist on disk or in S3.
|
||||
s.vlogf("rp E H:%s fetch RC:mem B:%d (%v elapsed)", hash, len(body), time.Since(start))
|
||||
}
|
||||
} else {
|
||||
setXCacheInfo(rsp.Header, "fetch, cached", hash)
|
||||
updateCache = func() {
|
||||
body := buf.Bytes()
|
||||
if err := s.cacheStoreLocal(hash, rsp.Header, body); err != nil {
|
||||
s.rspSaveError.Add(1)
|
||||
s.logf("save %q to cache: %v", hash, err)
|
||||
|
||||
// N.B.: Don't bother trying to forward to S3 in this case.
|
||||
} else {
|
||||
s.rspSave.Add(1)
|
||||
s.rspSaveBytes.Add(int64(len(body)))
|
||||
s.start(s.cacheStoreS3(hash, rsp.Header, body))
|
||||
}
|
||||
s.vlogf("rp E H:%s fetch RC:yes B:%d (%v elapsed)", hash, len(body), time.Since(start))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
updateCache()
|
||||
}
|
||||
|
||||
// rewriteRequest rewrites the inbound request for routing to a target.
|
||||
func (s *Server) rewriteRequest(pr *httputil.ProxyRequest) {
|
||||
u, _ := url.ParseRequestURI(pr.In.RequestURI)
|
||||
u.Host = pr.In.Host
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
pr.Out.URL = u
|
||||
pr.Out.Host = u.Host
|
||||
}
|
||||
|
||||
type copyReader struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// makePath returns the local cache path for the specified request hash.
|
||||
func (s *Server) makePath(hash string) string { return filepath.Join(s.Local, hash[:2], hash) }
|
||||
|
||||
// makeKey returns the S3 object key for the specified request hash.
|
||||
func (s *Server) makeKey(hash string) string { return path.Join(s.KeyPrefix, hash[:2], hash) }
|
||||
|
||||
func (s *Server) logf(msg string, args ...any) {
|
||||
if s.Logf != nil {
|
||||
s.Logf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) vlogf(msg string, args ...any) {
|
||||
if s.LogRequests {
|
||||
s.logf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func hostMatchesTarget(host string, targets []string) bool {
|
||||
return slices.Contains(targets, host)
|
||||
}
|
||||
|
||||
// canCacheRequest reports whether r is a request whose response can be cached.
|
||||
func (s *Server) canCacheRequest(r *http.Request) bool {
|
||||
return r.Method == "GET" && !parseCacheControl(r.Header.Get("Cache-Control")).Keys.Has("no-store")
|
||||
}
|
||||
|
||||
// canCacheResponse reports whether r is a response whose body can be cached.
|
||||
func (s *Server) canCacheResponse(rsp *http.Response) bool {
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
cc := parseCacheControl(rsp.Header.Get("Cache-Control"))
|
||||
if cc.Keys.Has("no-store") {
|
||||
return false
|
||||
} else if cc.Keys.Has("immutable") {
|
||||
return true
|
||||
}
|
||||
|
||||
// We treat a response that is not immutable but requires validation as
|
||||
// cacheable if its max-age is so long it doesn't matter.
|
||||
const goodLongTime = 60 * 24 * time.Hour
|
||||
return cc.Keys.Has("must-revalidate") && cc.MaxAge > goodLongTime
|
||||
}
|
||||
|
||||
type cacheControl struct {
|
||||
Keys mapset.Set[string]
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
func parseCacheControl(s string) (out cacheControl) {
|
||||
for _, v := range strings.Split(s, ",") {
|
||||
key, val, ok := strings.Cut(strings.TrimSpace(v), "=")
|
||||
if ok && key == "max-age" {
|
||||
sec, err := strconv.Atoi(val)
|
||||
if err == nil {
|
||||
out.MaxAge = time.Duration(sec) * time.Second
|
||||
}
|
||||
}
|
||||
out.Keys.Add(key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// canMemoryCache reports whether r is a volatile response whose body can be
|
||||
// cached temporarily, and if so returns the maxmimum length of time the cache
|
||||
// entry should be valid for.
|
||||
func (s *Server) canMemoryCache(rsp *http.Response) (time.Duration, bool) {
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
return 0, false
|
||||
}
|
||||
cc := parseCacheControl(rsp.Header.Get("Cache-Control"))
|
||||
if cc.Keys.Has("no-store") || cc.Keys.Has("no-cache") {
|
||||
// While no-cache doesn't mean we can't cache it, it requires
|
||||
// re-validation before reusing the response, so treat that as if it were
|
||||
// no-store.
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// We'll cache things in memory if they aren't expected to last too long.
|
||||
if cc.MaxAge > 0 && cc.MaxAge < time.Hour {
|
||||
return cc.MaxAge, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// hashRequest generates the storage digest for the specified request URL.
|
||||
func hashRequestURL(u *url.URL) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(u.String())))
|
||||
}
|
||||
|
||||
// writeCachedResponse generates an HTTP response for a cached result using the
|
||||
// provided headers and body from the cache object.
|
||||
func writeCachedResponse(w http.ResponseWriter, hdr http.Header, body []byte) {
|
||||
wh := w.Header()
|
||||
for name, vals := range hdr {
|
||||
for _, val := range vals {
|
||||
wh.Add(name, val)
|
||||
}
|
||||
}
|
||||
w.Write(body)
|
||||
}
|
||||
169
go-cache-plugin-src/lib/s3util/s3util.go
Normal file
169
go-cache-plugin-src/lib/s3util/s3util.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package s3util defines some helpful utilities for working with S3.
|
||||
package s3util
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/creachadair/mds/value"
|
||||
)
|
||||
|
||||
// IsNotExist reports whether err is an error indicating the requested resource
|
||||
// was not found, taking into account S3 and standard library types.
|
||||
func IsNotExist(err error) bool {
|
||||
var e1 *types.NotFound
|
||||
var e2 *types.NoSuchKey
|
||||
if errors.As(err, &e1) || errors.As(err, &e2) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
// BucketRegion reports the specified region for the given bucket using the
|
||||
// GetBucketLocation API.
|
||||
func BucketRegion(ctx context.Context, bucket string) (string, error) {
|
||||
// The default AWS region, which we use for resolving the bucket location
|
||||
// and also serves as the fallback if the API reports an empty region name.
|
||||
// The API returns "" for buckets in this region for historical reasons.
|
||||
const defaultRegion = "us-east-1"
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(defaultRegion))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cli := s3.NewFromConfig(cfg)
|
||||
loc, err := cli.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: &bucket})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cmp.Or(string(loc.LocationConstraint), defaultRegion), nil
|
||||
}
|
||||
|
||||
// NewETagReader returns a new S3 ETag reader for the contents of r.
|
||||
func NewETagReader(r io.Reader) ETagReader {
|
||||
// Note: We use MD5 here because the S3 API requires it for an ETag, we do
|
||||
// not rely on it as a secure checksum.
|
||||
h := md5.New()
|
||||
return ETagReader{r: io.TeeReader(r, h), hash: h}
|
||||
}
|
||||
|
||||
// ETagReader implements the [io.Reader] interface by delegating to a nested
|
||||
// reader. The ETag method returns a correctly-formatted S3 ETag for all the
|
||||
// data that have been read so far (initially none).
|
||||
type ETagReader struct {
|
||||
r io.Reader
|
||||
hash hash.Hash
|
||||
}
|
||||
|
||||
// Read satisfies [io.Reader] by delegating to the wrapped reader.
|
||||
func (e ETagReader) Read(data []byte) (int, error) { return e.r.Read(data) }
|
||||
|
||||
// ETag returns a correctly-formatted S3 etag for the contents of e that have
|
||||
// been read so far.
|
||||
func (e ETagReader) ETag() string { return fmt.Sprintf("%x", e.hash.Sum(nil)) }
|
||||
|
||||
// Client is a wrapper for an S3 client that provides basic read and write
|
||||
// facilities to a specific bucket.
|
||||
type Client struct {
|
||||
Client *s3.Client
|
||||
Bucket string
|
||||
}
|
||||
|
||||
// Put writes the specified data to S3 under the given key.
|
||||
func (c *Client) Put(ctx context.Context, key string, data io.Reader) error {
|
||||
// Attempt to find the size of the input to send as a content length.
|
||||
// If we can't do this, let the SDK figure it out.
|
||||
var sizePtr *int64
|
||||
switch t := data.(type) {
|
||||
case sizer:
|
||||
sizePtr = value.Ptr(t.Size())
|
||||
case statter:
|
||||
fi, err := t.Stat()
|
||||
if err == nil {
|
||||
sizePtr = value.Ptr(fi.Size())
|
||||
}
|
||||
case io.Seeker:
|
||||
v, err := t.Seek(0, io.SeekEnd)
|
||||
if err == nil {
|
||||
sizePtr = &v
|
||||
|
||||
// Try to seek back to the beginning. If we cannot do this, fail out
|
||||
// so we don't try to write a partial object.
|
||||
_, err = t.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[unexpected] seek failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := c.Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &c.Bucket,
|
||||
Key: &key,
|
||||
Body: data,
|
||||
ContentLength: sizePtr,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns the contents of the specified key from S3. On success, the
|
||||
// returned reader contains the contents of the object, and the caller must
|
||||
// close the reader when finished.
|
||||
//
|
||||
// If the key is not found, the resulting error satisfies [fs.ErrNotExist].
|
||||
func (c *Client) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
|
||||
rsp, err := c.Client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &c.Bucket,
|
||||
Key: &key,
|
||||
})
|
||||
if err != nil {
|
||||
if IsNotExist(err) {
|
||||
return nil, -1, fmt.Errorf("key %q: %w", key, fs.ErrNotExist)
|
||||
}
|
||||
return nil, -1, err
|
||||
}
|
||||
return rsp.Body, *rsp.ContentLength, nil
|
||||
}
|
||||
|
||||
// GetData returns the contents of the specified key from S3. It is a shorthand
|
||||
// for calling Get followed by io.ReadAll on the result.
|
||||
func (c *Client) GetData(ctx context.Context, key string) ([]byte, error) {
|
||||
rc, _, err := c.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// PutCond writes the specified data to S3 under the given key if the key does
|
||||
// not already exist, or if its content differs from the given etag.
|
||||
// The etag is an MD5 of the expected contents, encoded as lowercase hex digits.
|
||||
// On success, written reports whether the object was written.
|
||||
func (c *Client) PutCond(ctx context.Context, key, etag string, data io.Reader) (written bool, _ error) {
|
||||
if _, err := c.Client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: &c.Bucket,
|
||||
Key: &key,
|
||||
IfMatch: &etag,
|
||||
}); err == nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, c.Put(ctx, key, data)
|
||||
}
|
||||
|
||||
// A sizer exports a Size method, e.g., [bytes.Reader] and similar.
|
||||
type sizer interface{ Size() int64 }
|
||||
|
||||
// A statter exports a Stat method, e.g., [os.File] and similar.
|
||||
type statter interface{ Stat() (fs.FileInfo, error) }
|
||||
42
go-cache-plugin-src/lib/s3util/s3util_test.go
Normal file
42
go-cache-plugin-src/lib/s3util/s3util_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package s3util_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tailscale/go-cache-plugin/lib/s3util"
|
||||
)
|
||||
|
||||
func TestETagReader(t *testing.T) {
|
||||
const testInput = "the once and future kitten"
|
||||
|
||||
want := md5.Sum([]byte(testInput))
|
||||
t.Logf("MD5(%q) = %x", testInput, want)
|
||||
|
||||
r := s3util.NewETagReader(strings.NewReader(testInput))
|
||||
|
||||
nr, err := io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
t.Fatalf("Copy failed; %v", err)
|
||||
} else if nr != int64(len(testInput)) {
|
||||
t.Errorf("Copied %d bytes, want %d", nr, len(testInput))
|
||||
}
|
||||
|
||||
etag := r.ETag()
|
||||
t.Logf("Got etag %s for input %q", etag, testInput)
|
||||
|
||||
got, err := hex.DecodeString(r.ETag())
|
||||
if err != nil {
|
||||
t.Fatalf("Result is not valid hex: %s", r.ETag())
|
||||
}
|
||||
if !bytes.Equal(got, want[:]) {
|
||||
t.Errorf("Wrong result: got %x, want %x", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user