Update
This commit is contained in:
1 parent
3530afb854
commit
6903fcff27
2146 files changed
+112256
-190703
No files matched your search
+329
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compserve
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// CompressWriter wraps w so that its response body is transparently
|
||||
// compressed, incrementally as the handler writes it, with the first
|
||||
// encoding from encodings (default: [DefaultEncodings]) that offers live
|
||||
// compression and that r accepts.
|
||||
//
|
||||
// The caller must call Close exactly once after the wrapped handler
|
||||
// returns, to finish any compressed stream:
|
||||
//
|
||||
// cw := compserve.CompressWriter(w, r)
|
||||
// defer cw.Close()
|
||||
// handler.ServeHTTP(cw, r)
|
||||
func CompressWriter(w http.ResponseWriter, r *http.Request, encodings ...Encoding) *compressWriter {
|
||||
if len(encodings) == 0 {
|
||||
encodings = DefaultEncodings
|
||||
}
|
||||
var encs []Encoding
|
||||
for _, e := range encodings {
|
||||
if e.Compress != nil {
|
||||
encs = append(encs, e)
|
||||
}
|
||||
}
|
||||
// Compression applies to plain responses of standard body-carrying
|
||||
// requests: not HEAD (no body), not CONNECT (tunnel), not protocol
|
||||
// upgrades, not range requests (range semantics apply to the
|
||||
// uncompressed representation).
|
||||
safe := (r.Method == httpm.GET || r.Method == httpm.POST) &&
|
||||
r.Header.Get("Upgrade") == "" &&
|
||||
r.Header.Get("Range") == ""
|
||||
return &compressWriter{
|
||||
ResponseWriter: w,
|
||||
encs: encs,
|
||||
ae: r.Header.Get("Accept-Encoding"),
|
||||
safe: safe,
|
||||
}
|
||||
}
|
||||
|
||||
// compressWriter is a ResponseWriter wrapper that compresses the response
|
||||
// body of dynamic handlers. See [CompressWriter].
|
||||
//
|
||||
// The body is compressed as it is written, with no buffering. The compress
|
||||
// decision is made once from the response headers, sniffing the first
|
||||
// write if the handler sets no Content-Type.
|
||||
//
|
||||
// Compressed responses are chunked: the compressed size is not known up
|
||||
// front, so a handler-set Content-Length is removed. zstd framing may add
|
||||
// a few bytes to incompressible data.
|
||||
//
|
||||
// Responses pass through uncompressed when they already have a
|
||||
// Content-Encoding, on non-GET/POST requests (HEAD, CONNECT, upgrades,
|
||||
// range requests), non-200 statuses, or incompressible content types (see
|
||||
// compressibleContentType).
|
||||
//
|
||||
// Every response gets Vary: Accept-Encoding. Strong ETags are weakened:
|
||||
// validators are representation-specific.
|
||||
type compressWriter struct {
|
||||
http.ResponseWriter
|
||||
encs []Encoding
|
||||
|
||||
ae string // Accept-Encoding header of the request
|
||||
safe bool // request is safe to compress the response of
|
||||
|
||||
code int // stashed status code; 0 until the first WriteHeader or Write
|
||||
decided bool // whether the compress-or-passthrough decision was made
|
||||
compress bool // decision outcome: stream through the compressor
|
||||
hijacked bool // connection hijacked; the wrapper is out of the path
|
||||
cw io.WriteCloser
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped ResponseWriter to http.ResponseController for
|
||||
// deadline support; Flush and Hijack stay handled by the wrapper.
|
||||
func (c *compressWriter) Unwrap() http.ResponseWriter {
|
||||
return c.ResponseWriter
|
||||
}
|
||||
|
||||
// Hijack switches the connection out from under the wrapper: after a
|
||||
// successful hijack the connection is raw, no compression applies, and
|
||||
// wrapper writes fail. Handlers reach it via http.NewResponseController or
|
||||
// a direct type assertion, so hijacked paths carry no io overhead from
|
||||
// this wrapper. ResponseController.Hijack unwraps through further
|
||||
// middleware below this wrapper.
|
||||
func (c *compressWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if !c.decided {
|
||||
// No response was produced; the handler will speak for itself on
|
||||
// the hijacked connection. Do not attach a compressor or send
|
||||
// response headers.
|
||||
c.decided = true
|
||||
} else if c.compress {
|
||||
// Compressed bytes were already streamed; finish the frame and
|
||||
// return the encoder to its pool before the connection leaves our
|
||||
// control.
|
||||
err := c.cw.Close()
|
||||
c.compress, c.cw = false, nil
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
c.hijacked = true
|
||||
return http.NewResponseController(c.ResponseWriter).Hijack()
|
||||
}
|
||||
|
||||
// WriteHeader stashes the status code, deciding early if the response is
|
||||
// already disqualified from compression. See [CompressWriter].
|
||||
func (c *compressWriter) WriteHeader(code int) {
|
||||
if c.hijacked {
|
||||
return // the handler owns the connection
|
||||
}
|
||||
if c.code != 0 {
|
||||
return // ignore superfluous calls, like net/http does
|
||||
}
|
||||
c.code = code
|
||||
if !c.mayCompress() {
|
||||
c.decide(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// mayCompress reports whether the stashed response could still be
|
||||
// compressed based on the request and the handler's headers alone.
|
||||
func (c *compressWriter) mayCompress() bool {
|
||||
if len(c.encs) == 0 || c.code != http.StatusOK || !c.safe {
|
||||
return false
|
||||
}
|
||||
// Never double-encode (e.g. precompressed variants served by
|
||||
// [ServeFile] through this wrapper).
|
||||
return c.Header().Get("Content-Encoding") == ""
|
||||
}
|
||||
|
||||
// Write passes the body through the streaming compressor or the wrapped
|
||||
// writer, deciding on the first write. See [CompressWriter].
|
||||
func (c *compressWriter) Write(p []byte) (int, error) {
|
||||
if c.hijacked {
|
||||
return 0, http.ErrHijacked
|
||||
}
|
||||
if !c.decided {
|
||||
c.decide(p)
|
||||
}
|
||||
if c.compress {
|
||||
return c.cw.Write(p)
|
||||
}
|
||||
return c.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
// Flush flushes buffered compressed data and the underlying writer. See
|
||||
// [CompressWriter].
|
||||
func (c *compressWriter) Flush() {
|
||||
if !c.decided {
|
||||
c.decide(nil)
|
||||
}
|
||||
if c.compress {
|
||||
type flusher interface{ Flush() }
|
||||
if f, ok := c.cw.(flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
if f, ok := c.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Close finishes any compressed stream and must be called exactly once
|
||||
// after the wrapped handler returns. See [CompressWriter].
|
||||
func (c *compressWriter) Close() error {
|
||||
if c.hijacked {
|
||||
return nil // the connection left our control in Hijack
|
||||
}
|
||||
if !c.decided {
|
||||
c.decide(nil)
|
||||
}
|
||||
if c.compress {
|
||||
return c.cw.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decide makes the compress-or-passthrough decision from the request, the
|
||||
// response headers, and (when no Content-Type is set) the first body
|
||||
// bytes. It forwards the response headers.
|
||||
func (c *compressWriter) decide(first []byte) {
|
||||
c.decided = true
|
||||
if c.code == 0 {
|
||||
c.code = http.StatusOK
|
||||
}
|
||||
ct := c.Header().Get("Content-Type")
|
||||
sniffed := false
|
||||
if ct == "" && first != nil {
|
||||
// Mirror net/http's sniffing of the first bytes.
|
||||
ct = http.DetectContentType(first)
|
||||
sniffed = true
|
||||
}
|
||||
if !c.mayCompress() || ct == "" || !compressibleContentType(ct) {
|
||||
c.commitPassthrough()
|
||||
return
|
||||
}
|
||||
enc := negotiate(c.ae, c.encs)
|
||||
if enc == nil {
|
||||
c.commitPassthrough()
|
||||
return
|
||||
}
|
||||
cw, err := enc.Compress(c.ResponseWriter)
|
||||
if err != nil {
|
||||
c.commitPassthrough()
|
||||
return
|
||||
}
|
||||
|
||||
h := c.Header()
|
||||
if sniffed {
|
||||
// net/http would otherwise sniff the compressed bytes and label
|
||||
// the response application/octet-stream.
|
||||
h.Set("Content-Type", ct)
|
||||
}
|
||||
// The handler's Content-Length describes the uncompressed
|
||||
// representation; the compressed length is unknown, so the response is
|
||||
// chunked.
|
||||
h.Del("Content-Length")
|
||||
h.Set("Content-Encoding", enc.Token)
|
||||
// Strong validators must be representation-specific.
|
||||
if etag := h.Get("ETag"); etag != "" && !strings.HasPrefix(etag, "W/") {
|
||||
h.Set("ETag", "W/"+etag)
|
||||
}
|
||||
addVary(h, "Accept-Encoding")
|
||||
|
||||
c.cw = cw
|
||||
c.compress = true
|
||||
c.ResponseWriter.WriteHeader(c.code)
|
||||
}
|
||||
|
||||
// commitPassthrough forwards the stashed status to the wrapped writer;
|
||||
// subsequent writes stream through directly.
|
||||
func (c *compressWriter) commitPassthrough() {
|
||||
if c.code == 0 {
|
||||
c.code = http.StatusOK
|
||||
}
|
||||
addVary(c.Header(), "Accept-Encoding")
|
||||
c.ResponseWriter.WriteHeader(c.code)
|
||||
}
|
||||
|
||||
// compressibleContentType reports whether the given content type is worth
|
||||
// live compression. Most types are assumed compressible: only content that
|
||||
// is already compressed by construction is excluded (media codecs, archive
|
||||
// formats), except raw media types that are not, listed below.
|
||||
// +json/+xml suffix types are always compressed.
|
||||
func compressibleContentType(ct string) bool {
|
||||
ct = strings.ToLower(ct)
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = strings.TrimSpace(ct[:i])
|
||||
}
|
||||
if strings.HasSuffix(ct, "+json") || strings.HasSuffix(ct, "+xml") {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "audio/"):
|
||||
return rawAudioContentType(ct)
|
||||
case strings.HasPrefix(ct, "video/"):
|
||||
return rawVideoContentType(ct)
|
||||
}
|
||||
switch ct {
|
||||
case "application/zip",
|
||||
"application/gzip",
|
||||
"application/x-gzip",
|
||||
"application/x-bzip2",
|
||||
"application/x-xz",
|
||||
"application/zstd",
|
||||
"application/x-zstd",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/x-compress",
|
||||
"application/x-snappy-framed",
|
||||
"application/x-lz4",
|
||||
// Archive formats that are zip or other compressed containers.
|
||||
"application/java-archive", // jar: zip
|
||||
"application/vnd.android.package-archive", // apk: zip
|
||||
"application/x-apple-diskimage": // dmg: compressed
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// rawAudioContentType reports whether ct is an uncompressed audio format
|
||||
// worth compressing despite audio/ being excluded by default: audio content
|
||||
// is almost always compressed by its codec, but raw PCM is not.
|
||||
func rawAudioContentType(ct string) bool {
|
||||
switch ct {
|
||||
case "audio/wav", "audio/wave", "audio/x-wav", "audio/vnd.wave", // WAV: PCM
|
||||
"audio/pcm",
|
||||
"audio/l16", "audio/l24": // raw linear PCM, RFC 2586
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rawVideoContentType reports whether ct is an uncompressed video format
|
||||
// worth compressing despite video/ being excluded by default: video content
|
||||
// is almost always compressed by its codec, but raw frame data is not.
|
||||
func rawVideoContentType(ct string) bool {
|
||||
switch ct {
|
||||
case "video/raw", "video/x-raw", "video/x-raw-yuv",
|
||||
"video/x-y4m": // YUV4MPEG2
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addVary adds token to the response's Vary header, preserving any values
|
||||
// already set by the handler.
|
||||
func addVary(h http.Header, token string) {
|
||||
for _, vary := range h.Values("Vary") {
|
||||
for _, t := range strings.Split(vary, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(t), token) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Add("Vary", token)
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package compserve serves static files with content negotiation for
|
||||
// precompressed variants, and provides live compression for dynamic
|
||||
// responses.
|
||||
//
|
||||
// An [Encoding] describes a content coding's wire token and where its
|
||||
// precompressed variants live.
|
||||
package compserve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Encoding describes a single content coding (e.g. zstd) that this package
|
||||
// can negotiate and serve.
|
||||
type Encoding struct {
|
||||
// Token is the Content-Encoding token for this coding, e.g. "zstd".
|
||||
// Comparison against Accept-Encoding is case-insensitive.
|
||||
Token string
|
||||
|
||||
// Ext is the file name extension (including the leading dot) of
|
||||
// precompressed variants as produced by util/precompress, e.g. ".zst".
|
||||
Ext string
|
||||
|
||||
// Decompress, if non-nil, wraps f and yields its decompressed content,
|
||||
// taking ownership of f: the returned ReadCloser's Close closes f, and
|
||||
// if Decompress returns an error it must have closed f already.
|
||||
//
|
||||
// When Decompress is set, a precompressed variant can also be served to
|
||||
// clients that do not accept Token by decompressing it on the fly, so a
|
||||
// file system shipping only precompressed variants can still serve
|
||||
// every client. If nil, the variant is only served passthrough to
|
||||
// clients that accept Token.
|
||||
Decompress func(f fs.File) (io.ReadCloser, error)
|
||||
|
||||
// Compress, if non-nil, wraps w in a streaming compressor used by
|
||||
// [CompressWriter] for live (non-precompressed) response compression:
|
||||
// everything written to the returned WriteCloser is compressed and
|
||||
// forwarded to w, and its Close finishes the stream and releases any
|
||||
// pooled resources. Implementations should use a fast compression
|
||||
// mode: live compression runs per request and must not add meaningful
|
||||
// latency. If nil, the encoding is not offered for live compression.
|
||||
Compress func(w io.Writer) (io.WriteCloser, error)
|
||||
}
|
||||
|
||||
// DefaultEncodings is the set of encodings used when Options does not
|
||||
// specify any: zstd, then gzip for compatibility with file systems built
|
||||
// before zstd-only asset generation. Ties prefer the earlier encoding.
|
||||
var DefaultEncodings = []Encoding{Zstd, Gzip}
|
||||
|
||||
// Options configures ServeFile.
|
||||
type Options struct {
|
||||
// Encodings lists the precompressed variants available in the file
|
||||
// system, in preference order. The zero value selects
|
||||
// [DefaultEncodings].
|
||||
Encodings []Encoding
|
||||
}
|
||||
|
||||
// ServeFile serves the file at path from fsys to w, negotiating a
|
||||
// precompressed variant when the client accepts one and the variant exists.
|
||||
// Clients that do not accept any offered encoding are served the raw file;
|
||||
// if the raw file is absent (e.g. a file system shipping only
|
||||
// precompressed variants), a variant with a Decompress hook is served
|
||||
// decompressed instead.
|
||||
//
|
||||
// ServeFile sets Vary: Accept-Encoding on every response and serves the
|
||||
// content via http.ServeContent with the file's modification time (for
|
||||
// embedded file systems, wrap fsys in [tailscale.com/tsweb/vcstime.FS] to
|
||||
// supply one). Callers may set additional headers (e.g. Cache-Control)
|
||||
// before calling. If ServeFile returns an error it has not written a
|
||||
// response body, so callers may adjust headers and retry with a different
|
||||
// path. If no representation of the file exists, the returned error wraps
|
||||
// fs.ErrNotExist.
|
||||
func ServeFile(w http.ResponseWriter, r *http.Request, fsys fs.FS, path string, opts Options) error {
|
||||
encodings := opts.Encodings
|
||||
if encodings == nil {
|
||||
encodings = DefaultEncodings
|
||||
}
|
||||
if !fs.ValidPath(path) {
|
||||
// An invalid name can never exist. Report not-found rather than
|
||||
// the fs.ErrInvalid that validating file systems such as fs.Sub
|
||||
// return, so callers can fall back to another path.
|
||||
return &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
|
||||
}
|
||||
// The served representation (an encoded variant or identity) is
|
||||
// selected from Accept-Encoding, so caches must treat the response as
|
||||
// varying even when the negotiated representation is identity.
|
||||
addVary(w.Header(), "Accept-Encoding")
|
||||
|
||||
// Prefer serving an accepted variant passthrough: no server CPU cost.
|
||||
if enc := negotiate(r.Header.Get("Accept-Encoding"), encodings); enc != nil {
|
||||
if f, err := fsys.Open(path + enc.Ext); err == nil {
|
||||
rs, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
// Fall through to the raw file; it may still be servable.
|
||||
f.Close()
|
||||
} else {
|
||||
w.Header().Set("Content-Encoding", enc.Token)
|
||||
return serveContent(w, r, path, fileModTime(f), rs)
|
||||
}
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Serve the raw file if it exists: it is identical to what transcoding
|
||||
// a variant would produce, without the CPU cost.
|
||||
f, err := fsys.Open(path)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
rs, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
f.Close()
|
||||
return fmt.Errorf("%s is not seekable", path)
|
||||
}
|
||||
return serveContent(w, r, path, fileModTime(f), rs)
|
||||
}
|
||||
|
||||
// The raw file does not exist: transcode a precompressed variant to
|
||||
// identity for clients that do not accept it.
|
||||
for _, e := range encodings {
|
||||
if e.Decompress == nil {
|
||||
continue
|
||||
}
|
||||
f, err := fsys.Open(path + e.Ext)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
modTime := fileModTime(f)
|
||||
tf := &transcodedFile{fsys: fsys, path: path + e.Ext, dec: e.Decompress}
|
||||
if err := tf.start(f); err != nil {
|
||||
return err
|
||||
}
|
||||
return serveContent(w, r, path, modTime, tf)
|
||||
}
|
||||
return &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
// fileModTime reports f's modification time, or the zero time (which
|
||||
// disables conditional requests in http.ServeContent) if f does not stat.
|
||||
func fileModTime(f fs.File) time.Time {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return fi.ModTime()
|
||||
}
|
||||
|
||||
// serveContent serves rs via http.ServeContent, closing it if it is an
|
||||
// io.Closer.
|
||||
func serveContent(w http.ResponseWriter, r *http.Request, name string, modTime time.Time, rs io.ReadSeeker) error {
|
||||
http.ServeContent(w, r, name, modTime, rs)
|
||||
if c, ok := rs.(io.Closer); ok {
|
||||
// The response body has been written; a close error is not
|
||||
// actionable.
|
||||
c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// negotiate returns the encoding from offered that the given
|
||||
// Accept-Encoding header value accepts with the highest quality value, or
|
||||
// nil if it accepts none of them. Ties prefer the earlier entry in offered.
|
||||
func negotiate(ae string, offered []Encoding) *Encoding {
|
||||
if ae == "" {
|
||||
return nil
|
||||
}
|
||||
exact, wildcard, hasWildcard := parseAcceptEncoding(ae)
|
||||
var best *Encoding
|
||||
bestQ := 0.0
|
||||
for i := range offered {
|
||||
enc := &offered[i]
|
||||
q, ok := exact[strings.ToLower(enc.Token)]
|
||||
if !ok && hasWildcard {
|
||||
q, ok = wildcard, true
|
||||
}
|
||||
// A quality of zero explicitly rejects the coding, and equal
|
||||
// qualities prefer the earlier entry in offered.
|
||||
if ok && q > bestQ {
|
||||
best, bestQ = enc, q
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// parseAcceptEncoding parses an Accept-Encoding header value into the
|
||||
// quality values of the explicitly named (lowercased) coding tokens and of
|
||||
// the "*" wildcard, if present. Quality values default to 1 per RFC 9110,
|
||||
// and a malformed quality value rejects the coding.
|
||||
func parseAcceptEncoding(h string) (exact map[string]float64, wildcard float64, hasWildcard bool) {
|
||||
exact = make(map[string]float64)
|
||||
for _, part := range strings.Split(h, ",") {
|
||||
token, params, _ := strings.Cut(part, ";")
|
||||
token = strings.ToLower(strings.TrimSpace(token))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
q := 1.0
|
||||
for _, p := range strings.FieldsFunc(params, func(r rune) bool { return r == ';' }) {
|
||||
name, value, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||
if !strings.EqualFold(name, "q") {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil {
|
||||
q = v
|
||||
} else {
|
||||
q = 0
|
||||
}
|
||||
break
|
||||
}
|
||||
if token == "*" {
|
||||
wildcard, hasWildcard = q, true
|
||||
continue
|
||||
}
|
||||
exact[token] = q
|
||||
}
|
||||
return exact, wildcard, hasWildcard
|
||||
}
|
||||
|
||||
// transcodedFile serves the decompressed content of a precompressed file to
|
||||
// clients that do not accept its encoding. It implements io.ReadSeeker so
|
||||
// that it can be used with http.ServeContent; seeking re-opens the
|
||||
// underlying file and decompresses from the start, so it is only suitable
|
||||
// for small files where the double decompression cost is acceptable.
|
||||
type transcodedFile struct {
|
||||
fsys fs.FS
|
||||
path string // path of the compressed file in fsys
|
||||
dec func(f fs.File) (io.ReadCloser, error) // decompressor for that file
|
||||
rc io.ReadCloser // current decompressed stream
|
||||
pos int64 // current position in the decompressed stream
|
||||
}
|
||||
|
||||
// start replaces the decompressed stream with one reading f (whose
|
||||
// ownership is taken) from its start.
|
||||
func (t *transcodedFile) start(f fs.File) error {
|
||||
rc, err := t.dec(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.closeStream()
|
||||
t.rc, t.pos = rc, 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// restart replaces the decompressed stream with a fresh one reading the
|
||||
// underlying file from its start.
|
||||
func (t *transcodedFile) restart() error {
|
||||
f, err := t.fsys.Open(t.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.start(f)
|
||||
}
|
||||
|
||||
func (t *transcodedFile) closeStream() {
|
||||
if t.rc != nil {
|
||||
t.rc.Close()
|
||||
t.rc = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *transcodedFile) Read(p []byte) (int, error) {
|
||||
if t.rc == nil {
|
||||
return 0, fs.ErrClosed
|
||||
}
|
||||
n, err := t.rc.Read(p)
|
||||
t.pos += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (t *transcodedFile) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
if err := t.restart(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, err := io.CopyN(io.Discard, t, offset)
|
||||
return t.pos, err
|
||||
case io.SeekCurrent:
|
||||
if offset < 0 {
|
||||
return 0, fmt.Errorf("negative offset for SeekCurrent: %w", fs.ErrInvalid)
|
||||
}
|
||||
_, err := io.CopyN(io.Discard, t, offset)
|
||||
return t.pos, err
|
||||
case io.SeekEnd:
|
||||
if offset != 0 {
|
||||
return 0, fmt.Errorf("non-zero offset for SeekEnd: %w", fs.ErrInvalid)
|
||||
}
|
||||
_, err := io.Copy(io.Discard, t)
|
||||
return t.pos, err
|
||||
}
|
||||
return 0, fs.ErrInvalid
|
||||
}
|
||||
|
||||
func (t *transcodedFile) Close() error {
|
||||
t.closeStream()
|
||||
return nil
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compserve
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Gzip serves gzip-precompressed variants ("*.gz"). It exists for
|
||||
// transitional compatibility with file systems built before zstd-only
|
||||
// asset generation, such as web-client-prebuilt modules published before
|
||||
// cmd/build-webclient stopped writing gzip; file systems built since
|
||||
// contain no .gz variants and never select it.
|
||||
var Gzip = Encoding{
|
||||
Token: "gzip",
|
||||
Ext: ".gz",
|
||||
Decompress: decompressGzip,
|
||||
}
|
||||
|
||||
// decompressGzip wraps f in a streaming gzip decompressor, taking
|
||||
// ownership of f.
|
||||
func decompressGzip(f fs.File) (io.ReadCloser, error) {
|
||||
zr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &gzipFile{f: f, Reader: zr}, nil
|
||||
}
|
||||
|
||||
// gzipFile is a gzip.Reader reading from f; Close closes both.
|
||||
type gzipFile struct {
|
||||
f fs.File
|
||||
*gzip.Reader
|
||||
}
|
||||
|
||||
func (g *gzipFile) Close() error {
|
||||
g.Reader.Close()
|
||||
return g.f.Close()
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compserve
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"tailscale.com/util/zstdframe"
|
||||
)
|
||||
|
||||
// Zstd serves zstd-precompressed variants ("*.zst", as generated by
|
||||
// util/precompress). [Encoding.Decompress] lets ServeFile transcode the
|
||||
// variant for clients that do not accept zstd.
|
||||
var Zstd = Encoding{
|
||||
Token: "zstd",
|
||||
Ext: ".zst",
|
||||
Decompress: decompressZstd,
|
||||
Compress: compressZstdLive,
|
||||
}
|
||||
|
||||
// compressZstdLive wraps w in a streaming zstd compressor using the fastest
|
||||
// compression level and a small window suitable for low-latency live
|
||||
// compression. Close on the returned writer finishes the zstd frame and
|
||||
// returns the encoder to the shared pool.
|
||||
func compressZstdLive(w io.Writer) (io.WriteCloser, error) {
|
||||
enc, put := zstdframe.GetStreamingEncoder(
|
||||
zstdframe.FastestCompression,
|
||||
zstdframe.MaxWindowSize(64<<10),
|
||||
)
|
||||
// Encoder.Reset (streaming) cannot fail; only Decoder.Reset can.
|
||||
enc.Reset(w)
|
||||
return &pooledZstdWriter{Encoder: enc, put: put}, nil
|
||||
}
|
||||
|
||||
// pooledZstdWriter is a zstd.Encoder writing to the underlying writer;
|
||||
// Close finishes the frame and returns the encoder to the shared pool.
|
||||
type pooledZstdWriter struct {
|
||||
put func()
|
||||
closed bool
|
||||
*zstd.Encoder
|
||||
}
|
||||
|
||||
func (p *pooledZstdWriter) Close() error {
|
||||
if p.closed {
|
||||
return nil
|
||||
}
|
||||
p.closed = true
|
||||
err := p.Encoder.Close()
|
||||
p.put()
|
||||
return err
|
||||
}
|
||||
|
||||
// decompressZstd wraps f in a streaming zstd decompressor backed by the
|
||||
// shared decoder pool in util/zstdframe (concurrency=1, so streaming decode
|
||||
// neither spawns goroutines nor retains unbounded memory). It takes
|
||||
// ownership of f.
|
||||
func decompressZstd(f fs.File) (io.ReadCloser, error) {
|
||||
dec, put := zstdframe.GetDecoder()
|
||||
if err := dec.Reset(f); err != nil {
|
||||
put()
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &pooledZstdReader{f: f, put: put, Decoder: dec}, nil
|
||||
}
|
||||
|
||||
// pooledZstdReader is a zstd.Decoder reading from f; Close returns the
|
||||
// decoder to the shared pool and closes f.
|
||||
type pooledZstdReader struct {
|
||||
f fs.File
|
||||
put func()
|
||||
*zstd.Decoder
|
||||
}
|
||||
|
||||
func (p *pooledZstdReader) Close() error {
|
||||
p.put()
|
||||
return p.f.Close()
|
||||
}
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tsweb
|
||||
@@ -41,6 +41,11 @@ type DebugHandler struct {
|
||||
// tailscale.com/tsweb/promvarz package to enable this feature.
|
||||
var PrometheusHandler feature.Hook[func(*DebugHandler)]
|
||||
|
||||
// hookRuntimeMetrics registers the Go runtime/metrics debug page on a
|
||||
// DebugHandler. It is set by runtimemetrics.go, which is excluded from
|
||||
// js/wasm builds to keep them small.
|
||||
var hookRuntimeMetrics feature.Hook[func(*DebugHandler)]
|
||||
|
||||
// Debugger returns the DebugHandler registered on mux at /debug/,
|
||||
// creating it if necessary.
|
||||
func Debugger(mux *http.ServeMux) *DebugHandler {
|
||||
@@ -57,6 +62,9 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
|
||||
ret.KVFunc("Uptime", func() any { return varz.Uptime() })
|
||||
ret.KV("Version", version.Long())
|
||||
ret.Handle("vars", "Metrics (Go)", expvar.Handler())
|
||||
if f, ok := hookRuntimeMetrics.GetOk(); ok {
|
||||
f(ret)
|
||||
}
|
||||
if PrometheusHandler.IsSet() {
|
||||
PrometheusHandler.Get()(ret)
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tsweb
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !js && !wasm
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build js && wasm
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tsweb
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/ctxkey"
|
||||
"tailscale.com/util/rands"
|
||||
)
|
||||
@@ -47,7 +48,7 @@ func GenerateRequestID() RequestID {
|
||||
// Now we return "REQ-2" version, where the "2" doubles as the year 2YYY
|
||||
// in a leading date.
|
||||
now := time.Now().UTC()
|
||||
return RequestID("REQ-" + now.Format("20060102150405") + rands.HexString(16))
|
||||
return RequestID("REQ-" + now.Format(tstime.NumericDateTime) + rands.HexString(16))
|
||||
}
|
||||
|
||||
// SetRequestID is an HTTP middleware that injects a RequestID in the
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !js && !wasm
|
||||
|
||||
package tsweb
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime/metrics"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// runtimeMetricsDescs returns the descriptions of all supported Go
|
||||
// runtime/metrics, sorted by name.
|
||||
var runtimeMetricsDescs = sync.OnceValue(func() []metrics.Description {
|
||||
descs := metrics.All()
|
||||
slices.SortFunc(descs, func(a, b metrics.Description) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return descs
|
||||
})
|
||||
|
||||
// runtimeMetricsSlug is the path under /debug/ where
|
||||
// runtimeMetricsHandler is registered, both as an exact path and as a
|
||||
// subtree (with a trailing slash).
|
||||
const runtimeMetricsSlug = "runtime-metrics"
|
||||
|
||||
// runtimeMetricsPath is the full path of the runtime/metrics debug page.
|
||||
const runtimeMetricsPath = "/debug/" + runtimeMetricsSlug
|
||||
|
||||
func init() {
|
||||
hookRuntimeMetrics.Set(addRuntimeMetricsHandlers)
|
||||
}
|
||||
|
||||
// addRuntimeMetricsHandlers registers runtimeMetricsHandler on d, both
|
||||
// at its base path and as a subtree for the per-metric path form.
|
||||
func addRuntimeMetricsHandlers(d *DebugHandler) {
|
||||
d.Handle(runtimeMetricsSlug, "Metrics (Go runtime/metrics)", http.HandlerFunc(runtimeMetricsHandler))
|
||||
d.HandleSilent(runtimeMetricsSlug+"/", http.HandlerFunc(runtimeMetricsHandler))
|
||||
}
|
||||
|
||||
// runtimeMetricsHandler serves Go runtime/metrics.
|
||||
//
|
||||
// At its base path without a "name" query parameter it serves an index
|
||||
// of all supported metric names and their descriptions, without reading
|
||||
// any values. The index is HTML for browsers (requests whose Accept
|
||||
// header includes text/html) and plain text otherwise; the "format"
|
||||
// query parameter ("html" or "text") overrides that choice.
|
||||
//
|
||||
// With one or more "name" query parameters it reads the named metrics
|
||||
// and returns them as a JSON object keyed by metric name. A name
|
||||
// ending in "*" matches all metrics with that prefix, so "name=*"
|
||||
// returns everything and "name=/gc/*" returns the GC metrics.
|
||||
//
|
||||
// A metric name may also be appended to the base path, as in
|
||||
// /debug/runtime-metrics/gc/heap/allocs:bytes, in which case the
|
||||
// response is that metric's bare JSON value without the object wrapper.
|
||||
//
|
||||
// Histograms are objects with "counts" and "buckets" arrays; infinite
|
||||
// bucket boundaries are encoded as the strings "-Inf" and "+Inf".
|
||||
func runtimeMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if rest, ok := strings.CutPrefix(r.URL.Path, runtimeMetricsPath+"/"); ok {
|
||||
serveRuntimeMetricValue(w, "/"+rest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if names := r.Form["name"]; len(names) > 0 {
|
||||
serveRuntimeMetricsJSON(w, names)
|
||||
return
|
||||
}
|
||||
|
||||
format := r.FormValue("format")
|
||||
if format == "" {
|
||||
if strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
format = "html"
|
||||
} else {
|
||||
format = "text"
|
||||
}
|
||||
}
|
||||
switch format {
|
||||
case "html":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
case "text":
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
default:
|
||||
http.Error(w, "unknown format; want html or text", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
bw := bufio.NewWriter(w)
|
||||
defer bw.Flush()
|
||||
if format == "html" {
|
||||
writeRuntimeMetricsIndexHTML(bw)
|
||||
} else {
|
||||
writeRuntimeMetricsIndexText(bw)
|
||||
}
|
||||
}
|
||||
|
||||
// serveRuntimeMetricsJSON reads the metrics matching names (exact
|
||||
// names, or prefixes ending in "*") and writes them as a JSON object.
|
||||
func serveRuntimeMetricsJSON(w http.ResponseWriter, names []string) {
|
||||
descs := runtimeMetricsDescs()
|
||||
var samples []metrics.Sample
|
||||
seen := map[string]bool{}
|
||||
add := func(name string) {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
samples = append(samples, metrics.Sample{Name: name})
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if prefix, ok := strings.CutSuffix(name, "*"); ok {
|
||||
for _, d := range descs {
|
||||
if strings.HasPrefix(d.Name, prefix) {
|
||||
add(d.Name)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !runtimeMetricExists(name) {
|
||||
http.Error(w, fmt.Sprintf("unknown runtime metric %q", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
add(name)
|
||||
}
|
||||
metrics.Read(samples)
|
||||
|
||||
obj := make(map[string]any, len(samples))
|
||||
for _, s := range samples {
|
||||
obj[s.Name] = runtimeMetricValueJSON(s.Value)
|
||||
}
|
||||
writeJSON(w, obj)
|
||||
}
|
||||
|
||||
// serveRuntimeMetricValue reads the single metric name and writes its
|
||||
// bare JSON value.
|
||||
func serveRuntimeMetricValue(w http.ResponseWriter, name string) {
|
||||
if !runtimeMetricExists(name) {
|
||||
http.Error(w, fmt.Sprintf("unknown runtime metric %q", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
samples := []metrics.Sample{{Name: name}}
|
||||
metrics.Read(samples)
|
||||
writeJSON(w, runtimeMetricValueJSON(samples[0].Value))
|
||||
}
|
||||
|
||||
func runtimeMetricExists(name string) bool {
|
||||
return slices.ContainsFunc(runtimeMetricsDescs(), func(d metrics.Description) bool { return d.Name == name })
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", "\t")
|
||||
e.Encode(v)
|
||||
}
|
||||
|
||||
// runtimeMetricValueJSON converts v to a value that encoding/json can
|
||||
// encode. Infinite and NaN floats, which JSON cannot represent, become
|
||||
// strings.
|
||||
func runtimeMetricValueJSON(v metrics.Value) any {
|
||||
switch v.Kind() {
|
||||
case metrics.KindUint64:
|
||||
return v.Uint64()
|
||||
case metrics.KindFloat64:
|
||||
return jsonFloat(v.Float64())
|
||||
case metrics.KindFloat64Histogram:
|
||||
h := v.Float64Histogram()
|
||||
buckets := make([]any, len(h.Buckets))
|
||||
for i, b := range h.Buckets {
|
||||
buckets[i] = jsonFloat(b)
|
||||
}
|
||||
return map[string]any{
|
||||
"counts": h.Counts,
|
||||
"buckets": buckets,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonFloat(f float64) any {
|
||||
if math.IsInf(f, 0) || math.IsNaN(f) {
|
||||
return strconv.FormatFloat(f, 'g', -1, 64)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func runtimeMetricKind(k metrics.ValueKind) string {
|
||||
switch k {
|
||||
case metrics.KindUint64:
|
||||
return "uint64"
|
||||
case metrics.KindFloat64:
|
||||
return "float64"
|
||||
case metrics.KindFloat64Histogram:
|
||||
return "histogram"
|
||||
default:
|
||||
return fmt.Sprintf("kind(%d)", k)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRuntimeMetricsIndexText(w io.Writer) {
|
||||
fmt.Fprintf(w, "# Go runtime/metrics.\n")
|
||||
fmt.Fprintf(w, "# Fetch one bare JSON value with %s/NAME, e.g. %s/gc/heap/allocs:bytes.\n", runtimeMetricsPath, runtimeMetricsPath)
|
||||
fmt.Fprintf(w, "# Fetch a JSON object of values with %s?name=NAME (repeatable).\n", runtimeMetricsPath)
|
||||
fmt.Fprintf(w, "# A trailing * on a name matches by prefix; name=* returns all values.\n\n")
|
||||
for _, d := range runtimeMetricsDescs() {
|
||||
fmt.Fprintf(w, "%s\n", d.Name)
|
||||
for line := range strings.Lines(d.Description) {
|
||||
fmt.Fprintf(w, " # %s\n", strings.TrimRight(line, "\n"))
|
||||
}
|
||||
fmt.Fprintf(w, " # %s", runtimeMetricKind(d.Kind))
|
||||
if d.Cumulative {
|
||||
fmt.Fprintf(w, ", cumulative")
|
||||
}
|
||||
fmt.Fprintf(w, "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeRuntimeMetricsIndexHTML(w io.Writer) {
|
||||
// valueURL returns the URL serving name's value. Exact metric names
|
||||
// are appended to the path and yield a bare JSON value. Wildcards
|
||||
// use the name query parameter and yield a JSON object.
|
||||
valueURL := func(name string) string {
|
||||
if strings.HasSuffix(name, "*") {
|
||||
return runtimeMetricsPath + "?name=" + url.QueryEscape(name)
|
||||
}
|
||||
return runtimeMetricsPath + name
|
||||
}
|
||||
fmt.Fprintf(w, `<html><head><title>runtime/metrics</title>
|
||||
<style>
|
||||
body { font-family: monospace; }
|
||||
table { border-collapse: collapse; }
|
||||
td, th { border: 1px solid #ccc; padding: 2px 6px; vertical-align: top; text-align: left; }
|
||||
.desc { color: #555; }
|
||||
tr.wild td { background: #eee; }
|
||||
</style></head><body>
|
||||
<h1>Go runtime/metrics</h1>
|
||||
<p>See <a href="https://pkg.go.dev/runtime/metrics">runtime/metrics</a>.
|
||||
Click a name to read its bare value as JSON. Values are not read for this index.
|
||||
Append <code>?format=text</code> for a plain text index.
|
||||
Fetch a JSON object of several values with repeated <code>?name=</code> parameters; a trailing
|
||||
<code>*</code> matches by prefix (<a href="%s">all values</a>, <a href="%s">GC values</a>).</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Kind</th><th>Description</th></tr>
|
||||
`, valueURL("*"), valueURL("/gc/*"))
|
||||
var lastDir string
|
||||
for _, d := range runtimeMetricsDescs() {
|
||||
// Before each run of metrics sharing a directory, emit a row
|
||||
// linking each ancestor directory to its wildcard query, so
|
||||
// "/gc/heap/allocs:bytes" is preceded by links to "/gc/*" and
|
||||
// "/gc/heap/*".
|
||||
if dir := d.Name[:strings.LastIndex(d.Name, "/")+1]; dir != lastDir {
|
||||
lastDir = dir
|
||||
io.WriteString(w, `<tr class="wild"><td colspan="3">`)
|
||||
for i := 1; i < len(dir); i++ {
|
||||
if dir[i] != '/' {
|
||||
continue
|
||||
}
|
||||
pat := dir[:i+1] + "*"
|
||||
fmt.Fprintf(w, ` <a href="%s">%s</a>`, valueURL(pat), html.EscapeString(pat))
|
||||
}
|
||||
io.WriteString(w, "</td></tr>\n")
|
||||
}
|
||||
kind := runtimeMetricKind(d.Kind)
|
||||
if d.Cumulative {
|
||||
kind += ", cumulative"
|
||||
}
|
||||
fmt.Fprintf(w, `<tr><td><a href="%s">%s</a></td><td>%s</td><td class="desc">%s</td></tr>`+"\n",
|
||||
valueURL(d.Name), html.EscapeString(d.Name), kind, html.EscapeString(d.Description))
|
||||
}
|
||||
io.WriteString(w, "</table></body></html>\n")
|
||||
}
|
||||
+75
-6
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package tsweb contains code used in various Tailscale webservers.
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"expvar"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -53,6 +55,50 @@ func IsProd443(addr string) bool {
|
||||
return port == "443" || port == "https"
|
||||
}
|
||||
|
||||
// debugTrustedCIDRs is the envknob for TS_DEBUG_TRUSTED_CIDRS, a
|
||||
// comma-separated list of CIDR ranges (e.g. "10.0.0.0/8,172.16.0.0/12")
|
||||
// whose source IPs are allowed to access debug endpoints without Tailscale
|
||||
// authentication. This will supersede both IsTailscaleIP() and
|
||||
// TS_ALLOW_DEBUG_IP.
|
||||
var debugTrustedCIDRs = envknob.RegisterString("TS_DEBUG_TRUSTED_CIDRS")
|
||||
|
||||
// trustedCIDRs returns the parsed CIDR prefixes from TS_DEBUG_TRUSTED_CIDRS.
|
||||
var trustedCIDRs = sync.OnceValue(func() []netip.Prefix {
|
||||
return parseTrustedCIDRs(debugTrustedCIDRs())
|
||||
})
|
||||
|
||||
// parseTrustedCIDRs parses a comma-separated list of CIDR prefixes.
|
||||
// It fatals on invalid entries, consistent with other envknob parsing.
|
||||
func parseTrustedCIDRs(raw string) []netip.Prefix {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var prefixes []netip.Prefix
|
||||
for s := range strings.SplitSeq(raw, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid CIDR in TS_DEBUG_TRUSTED_CIDRS: %q: %v", s, err)
|
||||
}
|
||||
prefixes = append(prefixes, pfx)
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// cidrsContain checks if the source IP is associated with one of the
|
||||
// provided cidrs.
|
||||
func cidrsContain(cidrs []netip.Prefix, ip netip.Addr) bool {
|
||||
for _, pfx := range cidrs {
|
||||
if pfx.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowDebugAccess reports whether r should be permitted to access
|
||||
// various debug endpoints.
|
||||
func AllowDebugAccess(r *http.Request) bool {
|
||||
@@ -74,6 +120,9 @@ func AllowDebugAccess(r *http.Request) bool {
|
||||
if tsaddr.IsTailscaleIP(ip) || ip.IsLoopback() || ipStr == envknob.String("TS_ALLOW_DEBUG_IP") {
|
||||
return true
|
||||
}
|
||||
if cidrsContain(trustedCIDRs(), ip) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -687,6 +736,13 @@ func (lg loggingResponseWriter) Flush() {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// FlushError is the error-returning form of Flush that
|
||||
// [http.ResponseController.Flush] prefers, so handlers can see write
|
||||
// errors (such as an expired write deadline) that Flush would swallow.
|
||||
func (lg *loggingResponseWriter) FlushError() error {
|
||||
return http.NewResponseController(lg.ResponseWriter).Flush()
|
||||
}
|
||||
|
||||
func (lg *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return lg.ResponseWriter
|
||||
}
|
||||
@@ -734,8 +790,14 @@ func (h errorHandler) handleError(w http.ResponseWriter, r *http.Request, lw *lo
|
||||
|
||||
// Extract a presentable, loggable error.
|
||||
var hOK bool
|
||||
var hErr HTTPError
|
||||
if errors.As(err, &hErr) {
|
||||
hErr, hAsOK := errors.AsType[HTTPError](err)
|
||||
if !hAsOK {
|
||||
if hs, ok := errors.AsType[HTTPStatuser](err); ok {
|
||||
hErr = hs.HTTPStatus()
|
||||
hAsOK = true
|
||||
}
|
||||
}
|
||||
if hAsOK {
|
||||
hOK = true
|
||||
if hErr.Code == 0 {
|
||||
lw.logf("[unexpected] HTTPError %v did not contain an HTTP status code, sending internal server error", hErr)
|
||||
@@ -854,9 +916,7 @@ func WriteHTTPError(w http.ResponseWriter, r *http.Request, e HTTPError) {
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// Custom headers from the error.
|
||||
for k, vs := range e.Header {
|
||||
h[k] = vs
|
||||
}
|
||||
maps.Copy(h, e.Header)
|
||||
|
||||
// Write the msg back to the user.
|
||||
w.WriteHeader(e.Code)
|
||||
@@ -872,6 +932,15 @@ func WriteHTTPError(w http.ResponseWriter, r *http.Request, e HTTPError) {
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPStatuser is an optional interface implemented by errors that
|
||||
// carry an intended HTTP response. Handlers translating errors to
|
||||
// HTTP should honour the returned HTTPError rather than defaulting to
|
||||
// 500.
|
||||
type HTTPStatuser interface {
|
||||
error
|
||||
HTTPStatus() HTTPError
|
||||
}
|
||||
|
||||
// HTTPError is an error with embedded HTTP response information.
|
||||
//
|
||||
// It is the error type to be (optionally) used by Handler.ServeHTTPReturn.
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !android
|
||||
|
||||
package varz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"expvar"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var capable any = hostGOAMD64Level()
|
||||
expvar.Publish("gauge_goamd64_capable", expvar.Func(func() any { return capable }))
|
||||
var compiled any = compiledGOAMD64Level()
|
||||
expvar.Publish("gauge_goamd64_compiled", expvar.Func(func() any { return compiled }))
|
||||
}
|
||||
|
||||
// goamd64LevelFlags maps each x86-64 microarchitecture level to the
|
||||
// /proc/cpuinfo flag names it requires beyond the previous level.
|
||||
// It matches the per-level feature checks in Go's runtime/asm_amd64.s.
|
||||
// Note that /proc/cpuinfo spells SSE3 as "pni" and LZCNT as "abm",
|
||||
// and the kernel clears the AVX and AVX-512 flags when the OS lacks
|
||||
// XSAVE support, so OSXSAVE needs no separate check.
|
||||
var goamd64LevelFlags = [...][]string{
|
||||
2: {"cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"},
|
||||
3: {"abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe"},
|
||||
4: {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"},
|
||||
}
|
||||
|
||||
// hostGOAMD64Level returns the maximum GOAMD64 microarchitecture
|
||||
// level (1-4) that the host CPU supports, regardless of what level
|
||||
// this binary was compiled for. It returns 0 on error.
|
||||
func hostGOAMD64Level() int {
|
||||
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for line := range bytes.Lines(cpuinfo) {
|
||||
name, rest, ok := bytes.Cut(line, []byte(":"))
|
||||
if ok && string(bytes.TrimSpace(name)) == "flags" {
|
||||
return goamd64Level(strings.Fields(string(rest)))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// goamd64Level returns the maximum GOAMD64 level (1-4) supported by
|
||||
// a CPU with the given /proc/cpuinfo flags.
|
||||
func goamd64Level(flags []string) int {
|
||||
has := make(map[string]bool, len(flags))
|
||||
for _, f := range flags {
|
||||
has[f] = true
|
||||
}
|
||||
level := 1
|
||||
for next := 2; next < len(goamd64LevelFlags); next++ {
|
||||
for _, f := range goamd64LevelFlags[next] {
|
||||
if !has[f] {
|
||||
return level
|
||||
}
|
||||
}
|
||||
level = next
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// compiledGOAMD64Level returns the GOAMD64 microarchitecture level
|
||||
// (1-4) that this binary was compiled for, as recorded in its build
|
||||
// info. It returns 0 if unknown.
|
||||
func compiledGOAMD64Level() int {
|
||||
bi, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
for _, s := range bi.Settings {
|
||||
if s.Key == "GOAMD64" {
|
||||
if v, ok := strings.CutPrefix(s.Value, "v"); ok && len(v) == 1 && v[0] >= '1' && v[0] <= '4' {
|
||||
return int(v[0] - '0')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package varz
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
"runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// runtimeMetricSpec describes one Go runtime/metrics metric that
|
||||
// [Handler] exports in Prometheus format.
|
||||
type runtimeMetricSpec struct {
|
||||
// name is the runtime/metrics name, such as "/gc/heap/allocs:bytes".
|
||||
name string
|
||||
|
||||
// typ is the Prometheus type: "gauge", "counter", or "histogram".
|
||||
// Counters get a "_total" suffix, as in the Prometheus naming
|
||||
// conventions.
|
||||
typ string
|
||||
|
||||
// buckets says how a histogram's runtime buckets map to Prometheus
|
||||
// buckets. It is required for histograms and ignored otherwise.
|
||||
buckets histBuckets
|
||||
|
||||
// hidden means the metric is read (because it backs one of the
|
||||
// legacy memstats_* metrics) but is not exported under its own
|
||||
// go_runtime_ name.
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// histBuckets says how the runtime's lower-bound-inclusive buckets
|
||||
// [lo, hi) of a histogram become Prometheus's upper-bound-inclusive
|
||||
// le buckets.
|
||||
type histBuckets uint8
|
||||
|
||||
const (
|
||||
// histTime is for the runtime's time histograms, which have
|
||||
// hundreds of buckets. They are reduced as described at
|
||||
// [runtimeHistMinBucket], and a runtime bucket [lo, hi) becomes
|
||||
// le=hi, which is only wrong for a value exactly equal to hi.
|
||||
histTime histBuckets = iota + 1
|
||||
|
||||
// histPowersOfTwo is for histograms whose observed values are all
|
||||
// exactly equal to bucket lower bounds, such as goroutine stack
|
||||
// sizes. A runtime bucket [lo, hi) becomes le=lo, so the labels
|
||||
// name the values they count, and the runtime's final catch-all
|
||||
// bucket is folded into +Inf.
|
||||
histPowersOfTwo
|
||||
|
||||
// histInts is for histograms of small integer values, such as
|
||||
// counts of GC cycles. A runtime bucket [lo, hi) becomes le=hi-1,
|
||||
// which is exact for integers.
|
||||
histInts
|
||||
)
|
||||
|
||||
// runtimeMetricSpecs is the set of Go runtime/metrics metrics that
|
||||
// [Handler] reads. Everything here is cheap for the runtime to read: no
|
||||
// metric in this list stops the world or walks the heap or the
|
||||
// goroutine list.
|
||||
//
|
||||
// The list is deliberately short. The hidden entries only back the
|
||||
// legacy memstats_* metrics. The rest are either specific to the
|
||||
// Tailscale fork of Go or have no runtime.MemStats equivalent and earn
|
||||
// their place on a dashboard.
|
||||
//
|
||||
// Names that the running toolchain does not know (such as the
|
||||
// /tailscale/ metrics when not built with the fork) are dropped on
|
||||
// first use, so it is fine for this list to be a superset of what
|
||||
// exists.
|
||||
//
|
||||
// Go's Cumulative metrics are declared as counters here and the rest
|
||||
// as gauges; TestRuntimeMetricSpecs checks that against the
|
||||
// runtime's own descriptions.
|
||||
var runtimeMetricSpecs = []runtimeMetricSpec{
|
||||
// These back the memstats_* metrics; see memstatsIndexes.
|
||||
{name: "/memory/classes/heap/objects:bytes", typ: "gauge", hidden: true},
|
||||
{name: "/gc/heap/allocs:bytes", typ: "counter", hidden: true},
|
||||
{name: "/memory/classes/total:bytes", typ: "gauge", hidden: true},
|
||||
{name: "/gc/heap/allocs:objects", typ: "counter", hidden: true},
|
||||
{name: "/gc/heap/frees:objects", typ: "counter", hidden: true},
|
||||
{name: "/gc/heap/tiny/allocs:objects", typ: "counter", hidden: true},
|
||||
{name: "/gc/cycles/total:gc-cycles", typ: "counter", hidden: true},
|
||||
{name: "/cpu/classes/gc/mark/idle:cpu-seconds", typ: "counter", hidden: true},
|
||||
|
||||
// GC CPU time as counters, so that rate() over a window works.
|
||||
// memstats_gc_cpu_fraction, derived from these two (less the
|
||||
// hidden idle mark time above), is a lifetime average and cannot
|
||||
// show a recent change.
|
||||
{name: "/cpu/classes/gc/total:cpu-seconds", typ: "counter"},
|
||||
{name: "/cpu/classes/total:cpu-seconds", typ: "counter"},
|
||||
|
||||
// Live heap after the last GC. Unlike memstats_heap_alloc it
|
||||
// excludes garbage not yet swept, so it is the number to compare
|
||||
// against GOMEMLIMIT or a container memory limit.
|
||||
{name: "/gc/heap/live:bytes", typ: "gauge"},
|
||||
|
||||
// How long runnable goroutines wait to be scheduled, and how long
|
||||
// GC stop-the-world pauses take. Neither is visible any other way.
|
||||
{name: "/sched/latencies:seconds", typ: "histogram", buckets: histTime},
|
||||
{name: "/sched/pauses/total/gc:seconds", typ: "histogram", buckets: histTime},
|
||||
|
||||
// OS threads owned by the runtime, for spotting thread leaks from
|
||||
// blocking syscalls or cgo.
|
||||
{name: "/sched/threads/total:threads", typ: "gauge"},
|
||||
|
||||
// Time spent blocked on mutexes. cmd/derper used to export this
|
||||
// itself as go_sync_mutex_wait_seconds.
|
||||
{name: "/sync/mutex/wait/total:seconds", typ: "counter"},
|
||||
|
||||
// The /tailscale/ metrics exist only in the Tailscale fork of Go
|
||||
// (github.com/tailscale/go), used when building with the
|
||||
// tailscale_go build tag.
|
||||
{name: "/tailscale/sched/goroutines-by-stack-size:bytes", typ: "histogram", buckets: histPowersOfTwo},
|
||||
{name: "/tailscale/sched/stacks/copied:bytes", typ: "counter"},
|
||||
{name: "/tailscale/sched/stacks/growths:events", typ: "counter"},
|
||||
{name: "/tailscale/sched/stacks/shrinks:events", typ: "counter"},
|
||||
{name: "/tailscale/sched/timers/tracked:timers", typ: "gauge"},
|
||||
{name: "/tailscale/sched/timers/zombies/chan:timers", typ: "gauge"},
|
||||
{name: "/tailscale/sched/timers/zombies/func:timers", typ: "gauge"},
|
||||
{name: "/tailscale/sched/timers/zombies/func/created:timers", typ: "counter"},
|
||||
{name: "/tailscale/sched/timers/zombies/func/removed:timers", typ: "counter"},
|
||||
{name: "/tailscale/sched/timers/zombies/func/lifetime:gc-cycles", typ: "histogram", buckets: histInts},
|
||||
{name: "/tailscale/sched/timers/zombies:timers", typ: "gauge"},
|
||||
}
|
||||
|
||||
// runtimeMetricPrefix is the namespace prefix of exported runtime
|
||||
// metrics. It is deliberately not "go_", which is the namespace used
|
||||
// by the Prometheus Go client's collector; binaries that also use that
|
||||
// client (see tailscale.com/tsweb/promvarz) would otherwise export
|
||||
// some series twice, and Prometheus rejects a scrape containing
|
||||
// duplicate series.
|
||||
const runtimeMetricPrefix = "go_runtime_"
|
||||
|
||||
// runtimeMetricPromName converts a runtime/metrics name such as
|
||||
// "/gc/heap/allocs:bytes" to a Prometheus metric name such as
|
||||
// "go_runtime_gc_heap_allocs_bytes_total". The conversion follows the
|
||||
// same rules as the Prometheus Go client's collector, other than the
|
||||
// namespace: the path becomes the name with slashes and dashes
|
||||
// replaced by underscores, the unit is appended, and counters get a
|
||||
// "_total" suffix.
|
||||
func runtimeMetricPromName(name, typ string) string {
|
||||
path, unit, _ := strings.Cut(strings.TrimPrefix(name, "/"), ":")
|
||||
r := strings.NewReplacer("/", "_", "-", "_")
|
||||
unit = strings.ReplaceAll(unit, "/", "_per_")
|
||||
unit = strings.ReplaceAll(unit, "*", "_")
|
||||
s := runtimeMetricPrefix + r.Replace(path) + "_" + r.Replace(unit)
|
||||
if typ == "counter" {
|
||||
s += "_total"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Time histograms from the runtime have hundreds of buckets, four per
|
||||
// power of two from 256ns (finer below that) to about a day. That is
|
||||
// far too many series for Prometheus, so they are reduced to one
|
||||
// bucket per factor of runtimeHistBucketFactor starting at
|
||||
// runtimeHistMinBucket, and everything above runtimeHistMaxBucket is
|
||||
// folded into the +Inf bucket. Because the runtime's bucket boundaries
|
||||
// are at powers of two times small integers, a factor of 4 starting at
|
||||
// 256ns lands exactly on runtime boundaries, so the reduction only
|
||||
// merges runtime buckets and never splits one.
|
||||
const (
|
||||
runtimeHistMinBucket = 256e-9 // seconds
|
||||
runtimeHistMaxBucket = 1.0 // seconds
|
||||
runtimeHistBucketFactor = 4
|
||||
)
|
||||
|
||||
// runtimeHistLayout describes how a runtime histogram's buckets are
|
||||
// merged into the exported Prometheus buckets. It is computed once per
|
||||
// metric from the runtime's bucket boundaries, which never change.
|
||||
type runtimeHistLayout struct {
|
||||
// lines[i] is the exported line prefix for output bucket i, up to
|
||||
// and including the space before the value:
|
||||
// `name_bucket{le="0.001"} `. The last entry is the +Inf bucket.
|
||||
lines [][]byte
|
||||
|
||||
// ends[i] is the number of leading runtime bucket counts summed
|
||||
// into output bucket i, which is cumulative like Prometheus
|
||||
// buckets. The last entry, for +Inf, is the total number of
|
||||
// runtime buckets.
|
||||
ends []int
|
||||
}
|
||||
|
||||
// newRuntimeHistLayout computes the export layout for a runtime
|
||||
// histogram with the given bucket boundaries (as in
|
||||
// [metrics.Float64Histogram.Buckets], so the runtime bucket i counts
|
||||
// values in [buckets[i], buckets[i+1])).
|
||||
func newRuntimeHistLayout(promName string, buckets []float64, kind histBuckets) *runtimeHistLayout {
|
||||
numCounts := len(buckets) - 1
|
||||
lay := new(runtimeHistLayout)
|
||||
addBucket := func(le float64, end int) {
|
||||
line := append([]byte(promName), `_bucket{le="`...)
|
||||
if math.IsInf(le, 1) {
|
||||
line = append(line, "+Inf"...)
|
||||
} else {
|
||||
line = strconv.AppendFloat(line, le, 'g', -1, 64)
|
||||
}
|
||||
line = append(line, `"} `...)
|
||||
lay.lines = append(lay.lines, line)
|
||||
lay.ends = append(lay.ends, end)
|
||||
}
|
||||
switch kind {
|
||||
case histPowersOfTwo:
|
||||
// All but the final catch-all runtime bucket, each labeled
|
||||
// with its lower bound.
|
||||
for i := 0; i < numCounts-1; i++ {
|
||||
addBucket(buckets[i], i+1)
|
||||
}
|
||||
case histInts:
|
||||
// All but the final catch-all runtime bucket, each labeled
|
||||
// with the largest integer it contains.
|
||||
for i := 1; i < numCounts; i++ {
|
||||
addBucket(buckets[i]-1, i)
|
||||
}
|
||||
case histTime:
|
||||
last := 0.0
|
||||
for i := 1; i < numCounts; i++ {
|
||||
hi := buckets[i]
|
||||
if math.IsInf(hi, 0) || hi < runtimeHistMinBucket {
|
||||
continue
|
||||
}
|
||||
if last > runtimeHistMaxBucket {
|
||||
break
|
||||
}
|
||||
if last == 0 || hi >= last*runtimeHistBucketFactor {
|
||||
addBucket(hi, i)
|
||||
last = hi
|
||||
}
|
||||
}
|
||||
}
|
||||
addBucket(math.Inf(1), numCounts)
|
||||
return lay
|
||||
}
|
||||
|
||||
// runtimeMetricState is a metric being exported, alongside its
|
||||
// [metrics.Sample] in [runtimeMetricsExporter.samples].
|
||||
type runtimeMetricState struct {
|
||||
spec runtimeMetricSpec
|
||||
|
||||
// typeLine is the "# TYPE name type\n" line.
|
||||
typeLine []byte
|
||||
|
||||
// valuePrefix is "name " for scalar metrics, or nil for histograms.
|
||||
valuePrefix []byte
|
||||
|
||||
// countPrefix is "name_count " for histograms, or nil otherwise.
|
||||
countPrefix []byte
|
||||
|
||||
// hist is the bucket layout for histograms, or nil otherwise.
|
||||
hist *runtimeHistLayout
|
||||
}
|
||||
|
||||
// runtimeMetricsExporter reads a fixed set of Go runtime/metrics and
|
||||
// writes them in Prometheus format. There is one, [runtimeMetrics],
|
||||
// and it keeps its sample slice across reads so that the runtime can
|
||||
// reuse histogram buffers and a steady-state scrape does not allocate.
|
||||
type runtimeMetricsExporter struct {
|
||||
mu sync.Mutex
|
||||
inited bool
|
||||
samples []metrics.Sample // reused across reads; parallel to states
|
||||
states []*runtimeMetricState // parallel to samples
|
||||
|
||||
// Indexes into samples of the metrics backing the legacy
|
||||
// memstats_* names, or -1 if the runtime lacks them.
|
||||
memstatsIdx memstatsIndexes
|
||||
}
|
||||
|
||||
// memstatsIndexes holds indexes into [runtimeMetricsExporter.samples]
|
||||
// for the runtime/metrics equivalents of the runtime.MemStats fields
|
||||
// that varz has exported as memstats_* since before runtime/metrics
|
||||
// existed. Those names are kept for existing dashboards, but they are
|
||||
// now read from runtime/metrics rather than runtime.ReadMemStats,
|
||||
// which stops the world.
|
||||
type memstatsIndexes struct {
|
||||
heapAlloc int // /memory/classes/heap/objects:bytes
|
||||
totalAlloc int // /gc/heap/allocs:bytes
|
||||
sys int // /memory/classes/total:bytes
|
||||
mallocs int // /gc/heap/allocs:objects
|
||||
frees int // /gc/heap/frees:objects
|
||||
tinyAllocs int // /gc/heap/tiny/allocs:objects
|
||||
numGC int // /gc/cycles/total:gc-cycles
|
||||
gcCPU int // /cpu/classes/gc/total:cpu-seconds
|
||||
gcIdleCPU int // /cpu/classes/gc/mark/idle:cpu-seconds
|
||||
totalCPU int // /cpu/classes/total:cpu-seconds
|
||||
}
|
||||
|
||||
// runtimeMetrics is the process-wide exporter used by [Handler].
|
||||
var runtimeMetrics = new(runtimeMetricsExporter)
|
||||
|
||||
// runtimeMetricsBufPool holds *[]byte output buffers so that a scrape
|
||||
// can be formatted under the exporter's lock and written to the
|
||||
// (possibly slow) client after releasing it.
|
||||
var runtimeMetricsBufPool = sync.Pool{New: func() any { return new([]byte) }}
|
||||
|
||||
// init reads every metric in [runtimeMetricSpecs] once, drops the ones
|
||||
// this toolchain does not know, and precomputes the per-metric output
|
||||
// prefixes and histogram layouts.
|
||||
//
|
||||
// e.mu must be held.
|
||||
func (e *runtimeMetricsExporter) init() {
|
||||
e.inited = true
|
||||
|
||||
samples := make([]metrics.Sample, len(runtimeMetricSpecs))
|
||||
for i, spec := range runtimeMetricSpecs {
|
||||
samples[i].Name = spec.name
|
||||
}
|
||||
metrics.Read(samples)
|
||||
|
||||
byName := map[string]int{}
|
||||
for i, spec := range runtimeMetricSpecs {
|
||||
s := samples[i]
|
||||
var ok bool
|
||||
switch s.Value.Kind() {
|
||||
case metrics.KindUint64, metrics.KindFloat64:
|
||||
ok = spec.typ != "histogram"
|
||||
case metrics.KindFloat64Histogram:
|
||||
ok = spec.typ == "histogram"
|
||||
}
|
||||
if !ok {
|
||||
// Unknown to this toolchain, or its kind changed
|
||||
// out from under our table.
|
||||
continue
|
||||
}
|
||||
st := &runtimeMetricState{spec: spec}
|
||||
if !spec.hidden {
|
||||
name := runtimeMetricPromName(spec.name, spec.typ)
|
||||
st.typeLine = []byte("# TYPE " + name + " " + spec.typ + "\n")
|
||||
if spec.typ == "histogram" {
|
||||
st.hist = newRuntimeHistLayout(name, s.Value.Float64Histogram().Buckets, spec.buckets)
|
||||
st.countPrefix = []byte(name + "_count ")
|
||||
} else {
|
||||
st.valuePrefix = []byte(name + " ")
|
||||
}
|
||||
}
|
||||
byName[spec.name] = len(e.samples)
|
||||
e.samples = append(e.samples, s)
|
||||
e.states = append(e.states, st)
|
||||
}
|
||||
|
||||
idx := func(name string) int {
|
||||
if i, ok := byName[name]; ok {
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
e.memstatsIdx = memstatsIndexes{
|
||||
heapAlloc: idx("/memory/classes/heap/objects:bytes"),
|
||||
totalAlloc: idx("/gc/heap/allocs:bytes"),
|
||||
sys: idx("/memory/classes/total:bytes"),
|
||||
mallocs: idx("/gc/heap/allocs:objects"),
|
||||
frees: idx("/gc/heap/frees:objects"),
|
||||
tinyAllocs: idx("/gc/heap/tiny/allocs:objects"),
|
||||
numGC: idx("/gc/cycles/total:gc-cycles"),
|
||||
gcCPU: idx("/cpu/classes/gc/total:cpu-seconds"),
|
||||
gcIdleCPU: idx("/cpu/classes/gc/mark/idle:cpu-seconds"),
|
||||
totalCPU: idx("/cpu/classes/total:cpu-seconds"),
|
||||
}
|
||||
}
|
||||
|
||||
// writeTo reads the current values of the exported metrics and writes
|
||||
// them to w in Prometheus text format, preceded by the legacy
|
||||
// memstats_* metrics.
|
||||
func (e *runtimeMetricsExporter) writeTo(w io.Writer) error {
|
||||
bp := runtimeMetricsBufPool.Get().(*[]byte)
|
||||
defer runtimeMetricsBufPool.Put(bp)
|
||||
|
||||
e.mu.Lock()
|
||||
if !e.inited {
|
||||
e.init()
|
||||
} else {
|
||||
metrics.Read(e.samples)
|
||||
}
|
||||
*bp = e.appendMemstats((*bp)[:0])
|
||||
for i, st := range e.states {
|
||||
if !st.spec.hidden {
|
||||
*bp = st.appendTo(*bp, &e.samples[i].Value)
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
_, err := w.Write(*bp)
|
||||
return err
|
||||
}
|
||||
|
||||
// appendTo appends the Prometheus lines for st with value v to b.
|
||||
func (st *runtimeMetricState) appendTo(b []byte, v *metrics.Value) []byte {
|
||||
b = append(b, st.typeLine...)
|
||||
switch v.Kind() {
|
||||
case metrics.KindUint64:
|
||||
b = append(b, st.valuePrefix...)
|
||||
b = strconv.AppendUint(b, v.Uint64(), 10)
|
||||
b = append(b, '\n')
|
||||
case metrics.KindFloat64:
|
||||
b = append(b, st.valuePrefix...)
|
||||
b = strconv.AppendFloat(b, v.Float64(), 'g', -1, 64)
|
||||
b = append(b, '\n')
|
||||
case metrics.KindFloat64Histogram:
|
||||
counts := v.Float64Histogram().Counts
|
||||
var sum uint64
|
||||
var next int
|
||||
for i, end := range st.hist.ends {
|
||||
for ; next < end && next < len(counts); next++ {
|
||||
sum += counts[next]
|
||||
}
|
||||
b = append(b, st.hist.lines[i]...)
|
||||
b = strconv.AppendUint(b, sum, 10)
|
||||
b = append(b, '\n')
|
||||
}
|
||||
b = append(b, st.countPrefix...)
|
||||
b = strconv.AppendUint(b, sum, 10)
|
||||
b = append(b, '\n')
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// appendMemstats appends the legacy memstats_* metrics to b, sourced
|
||||
// from the runtime/metrics samples named by e.memstatsIdx.
|
||||
//
|
||||
// e.mu must be held and e.samples must have been read.
|
||||
func (e *runtimeMetricsExporter) appendMemstats(b []byte) []byte {
|
||||
idx := &e.memstatsIdx
|
||||
u := func(i int) (uint64, bool) {
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
v := &e.samples[i].Value
|
||||
if v.Kind() != metrics.KindUint64 {
|
||||
return 0, false
|
||||
}
|
||||
return v.Uint64(), true
|
||||
}
|
||||
f := func(i int) (float64, bool) {
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
v := &e.samples[i].Value
|
||||
if v.Kind() != metrics.KindFloat64 {
|
||||
return 0, false
|
||||
}
|
||||
return v.Float64(), true
|
||||
}
|
||||
appendUint := func(typ, name string, v uint64, ok bool, help string) {
|
||||
if ok {
|
||||
b = appendMemstat(b, typ, name, help)
|
||||
b = strconv.AppendUint(b, v, 10)
|
||||
b = append(b, '\n')
|
||||
}
|
||||
}
|
||||
appendIdx := func(typ, name string, i int, help string) {
|
||||
v, ok := u(i)
|
||||
appendUint(typ, name, v, ok, help)
|
||||
}
|
||||
appendIdx("gauge", "heap_alloc", idx.heapAlloc, "current bytes of allocated heap objects (up/down smoothly)")
|
||||
appendIdx("counter", "total_alloc", idx.totalAlloc, "cumulative bytes allocated for heap objects")
|
||||
appendIdx("gauge", "sys", idx.sys, "total bytes of memory obtained from the OS")
|
||||
|
||||
// runtime.ReadMemStats counts tiny allocations in both Mallocs
|
||||
// and Frees, but /gc/heap/allocs:objects and /gc/heap/frees:objects
|
||||
// exclude them, so add them back.
|
||||
mallocs, ok1 := u(idx.mallocs)
|
||||
frees, ok2 := u(idx.frees)
|
||||
tiny, ok3 := u(idx.tinyAllocs)
|
||||
appendUint("counter", "mallocs", mallocs+tiny, ok1 && ok3, "cumulative count of heap objects allocated")
|
||||
appendUint("counter", "frees", frees+tiny, ok2 && ok3, "cumulative count of heap objects freed")
|
||||
|
||||
appendIdx("counter", "num_gc", idx.numGC, "number of completed GC cycles")
|
||||
gc, ok1 := f(idx.gcCPU)
|
||||
idle, ok2 := f(idx.gcIdleCPU)
|
||||
total, ok3 := f(idx.totalCPU)
|
||||
if ok1 && ok2 && ok3 {
|
||||
// This is how the runtime computes MemStats.GCCPUFraction:
|
||||
// GC time less idle-priority marking, which is "free", over
|
||||
// total time. The runtime updates its CPU class stats at
|
||||
// the end of each GC cycle, so total is 0 until the first
|
||||
// one, as GCCPUFraction was too.
|
||||
var frac float64
|
||||
if total > 0 {
|
||||
frac = (gc - idle) / total
|
||||
}
|
||||
b = appendMemstat(b, "gauge", "gc_cpu_fraction", "fraction of CPU time used by GC")
|
||||
b = strconv.AppendFloat(b, frac, 'f', -1, 64)
|
||||
b = append(b, '\n')
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// appendMemstat appends the HELP and TYPE lines for the memstats_*
|
||||
// metric name to b, followed by the metric name and a space, leaving
|
||||
// the caller to append the value and newline.
|
||||
func appendMemstat(b []byte, typ, name, help string) []byte {
|
||||
b = append(b, "# HELP memstats_"...)
|
||||
b = append(b, name...)
|
||||
b = append(b, ' ')
|
||||
b = append(b, help...)
|
||||
b = append(b, "\n# TYPE memstats_"...)
|
||||
b = append(b, name...)
|
||||
b = append(b, ' ')
|
||||
b = append(b, typ...)
|
||||
b = append(b, "\nmemstats_"...)
|
||||
b = append(b, name...)
|
||||
b = append(b, ' ')
|
||||
return b
|
||||
}
|
||||
+57
-64
@@ -1,11 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package varz contains code to export metrics in Prometheus format.
|
||||
package varz
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"expvar"
|
||||
"fmt"
|
||||
@@ -23,10 +22,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -42,8 +39,34 @@ func init() {
|
||||
expvar.Publish("go_version", StaticStringVar(runtime.Version()))
|
||||
expvar.Publish("counter_uptime_sec", expvar.Func(func() any { return int64(Uptime().Seconds()) }))
|
||||
expvar.Publish("gauge_goroutines", expvar.Func(func() any { return runtime.NumGoroutine() }))
|
||||
if v := nodeBootTime(); v != 0 {
|
||||
var vi any = v // box once
|
||||
// The name matches what Prometheus's node exporter uses
|
||||
// for the same value.
|
||||
expvar.Publish("node_boot_time_seconds", expvar.Func(func() any { return vi }))
|
||||
}
|
||||
}
|
||||
|
||||
// nodeBootTime returns the machine's boot time in Unix seconds,
|
||||
// as reported by the "btime" line of Linux's /proc/stat.
|
||||
// It returns 0 if unavailable, such as on non-Linux systems.
|
||||
var nodeBootTime = sync.OnceValue(func() int64 {
|
||||
stat, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for line := range strings.Lines(string(stat)) {
|
||||
if rest, ok := strings.CutPrefix(line, "btime "); ok {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return sec
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const (
|
||||
gaugePrefix = "gauge_"
|
||||
counterPrefix = "counter_"
|
||||
@@ -93,8 +116,8 @@ func prometheusMetric(prefix string, key string) (string, string, string) {
|
||||
typ = "histogram"
|
||||
key = strings.TrimPrefix(key, histogramPrefix)
|
||||
}
|
||||
if strings.HasPrefix(key, labelMapPrefix) {
|
||||
key = strings.TrimPrefix(key, labelMapPrefix)
|
||||
if after, ok := strings.CutPrefix(key, labelMapPrefix); ok {
|
||||
key = after
|
||||
if a, b, ok := strings.Cut(key, "_"); ok {
|
||||
label, key = a, b
|
||||
}
|
||||
@@ -154,7 +177,7 @@ func writePromExpVar(w io.Writer, prefix string, kv expvar.KeyValue) {
|
||||
case PrometheusMetricsReflectRooter:
|
||||
root := v.PrometheusMetricsReflectRoot()
|
||||
rv := reflect.ValueOf(root)
|
||||
if rv.Type().Kind() == reflect.Ptr {
|
||||
if rv.Type().Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -189,11 +212,14 @@ func writePromExpVar(w io.Writer, prefix string, kv expvar.KeyValue) {
|
||||
if typ == "" {
|
||||
var funcRet string
|
||||
if f, ok := kv.Value.(expvar.Func); ok {
|
||||
v := f()
|
||||
if ms, ok := v.(runtime.MemStats); ok && name == "memstats" {
|
||||
writeMemstats(w, &ms)
|
||||
if key == "memstats" {
|
||||
// The expvar package's own memstats var calls
|
||||
// runtime.ReadMemStats, which stops the world and
|
||||
// allocates. [Handler] exports the memstats_* metrics
|
||||
// from runtime/metrics instead, so don't even call it.
|
||||
return
|
||||
}
|
||||
v := f()
|
||||
if vs, ok := v.(string); ok && strings.HasSuffix(name, "version") {
|
||||
if name == "version" {
|
||||
fmt.Fprintf(w, "%s{version=%q,binary=%q} 1\n", name, vs, binaryName())
|
||||
@@ -245,11 +271,21 @@ func writePromExpVar(w io.Writer, prefix string, kv expvar.KeyValue) {
|
||||
if label != "" && typ != "" {
|
||||
fmt.Fprintf(w, "# TYPE %s %s\n", name, typ)
|
||||
v.Do(func(kv expvar.KeyValue) {
|
||||
fmt.Fprintf(w, "%s{%s=%q} %v\n", name, label, kv.Key, kv.Value)
|
||||
switch kv.Value.(type) {
|
||||
case *expvar.Int, *expvar.Float:
|
||||
fmt.Fprintf(w, "%s{%s=%q} %v\n", name, label, kv.Key, kv.Value)
|
||||
default:
|
||||
fmt.Fprintf(w, "# skipping %q expvar map key %q with unknown value type %T\n", name, kv.Key, kv.Value)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
v.Do(func(kv expvar.KeyValue) {
|
||||
fmt.Fprintf(w, "%s_%s %v\n", name, kv.Key, kv.Value)
|
||||
switch kv.Value.(type) {
|
||||
case *expvar.Int, *expvar.Float:
|
||||
fmt.Fprintf(w, "%s_%s %v\n", name, kv.Key, kv.Value)
|
||||
default:
|
||||
fmt.Fprintf(w, "# skipping %q expvar map key %q with unknown value type %T\n", name, kv.Key, kv.Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -292,13 +328,19 @@ type sortedKVs struct {
|
||||
// is not exported.
|
||||
//
|
||||
// This will evolve over time, or perhaps be replaced.
|
||||
//
|
||||
// It also exports the Go runtime/metrics in [runtimeMetricSpecs] and
|
||||
// the legacy memstats_* metrics, both read from runtime/metrics.
|
||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
ExpvarDoHandler(expvarDo)(w, r)
|
||||
runtimeMetrics.writeTo(w)
|
||||
}
|
||||
|
||||
// ExpvarDoHandler handler returns a Handler like above, but takes an optional
|
||||
// expvar.Do func allow the usage of alternative containers of metrics, other
|
||||
// than the global expvar.Map.
|
||||
//
|
||||
// Unlike [Handler], it does not export the Go runtime/metrics.
|
||||
func ExpvarDoHandler(expvarDoFunc func(f func(expvar.KeyValue))) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain;version=0.0.4;charset=utf-8")
|
||||
@@ -342,54 +384,6 @@ type PrometheusMetricsReflectRooter interface {
|
||||
|
||||
var expvarDo = expvar.Do // pulled out for tests
|
||||
|
||||
func writeMemstat[V constraints.Integer | constraints.Float](bw *bufio.Writer, typ, name string, v V, help string) {
|
||||
if help != "" {
|
||||
bw.WriteString("# HELP memstats_")
|
||||
bw.WriteString(name)
|
||||
bw.WriteString(" ")
|
||||
bw.WriteString(help)
|
||||
bw.WriteByte('\n')
|
||||
}
|
||||
bw.WriteString("# TYPE memstats_")
|
||||
bw.WriteString(name)
|
||||
bw.WriteString(" ")
|
||||
bw.WriteString(typ)
|
||||
bw.WriteByte('\n')
|
||||
bw.WriteString("memstats_")
|
||||
bw.WriteString(name)
|
||||
bw.WriteByte(' ')
|
||||
rt := reflect.TypeOf(v)
|
||||
switch {
|
||||
case rt == reflect.TypeFor[int]() ||
|
||||
rt == reflect.TypeFor[uint]() ||
|
||||
rt == reflect.TypeFor[int8]() ||
|
||||
rt == reflect.TypeFor[uint8]() ||
|
||||
rt == reflect.TypeFor[int16]() ||
|
||||
rt == reflect.TypeFor[uint16]() ||
|
||||
rt == reflect.TypeFor[int32]() ||
|
||||
rt == reflect.TypeFor[uint32]() ||
|
||||
rt == reflect.TypeFor[int64]() ||
|
||||
rt == reflect.TypeFor[uint64]() ||
|
||||
rt == reflect.TypeFor[uintptr]():
|
||||
bw.Write(strconv.AppendInt(bw.AvailableBuffer(), int64(v), 10))
|
||||
case rt == reflect.TypeFor[float32]() || rt == reflect.TypeFor[float64]():
|
||||
bw.Write(strconv.AppendFloat(bw.AvailableBuffer(), float64(v), 'f', -1, 64))
|
||||
}
|
||||
bw.WriteByte('\n')
|
||||
}
|
||||
|
||||
func writeMemstats(w io.Writer, ms *runtime.MemStats) {
|
||||
fmt.Fprintf(w, "%v", logger.ArgWriter(func(bw *bufio.Writer) {
|
||||
writeMemstat(bw, "gauge", "heap_alloc", ms.HeapAlloc, "current bytes of allocated heap objects (up/down smoothly)")
|
||||
writeMemstat(bw, "counter", "total_alloc", ms.TotalAlloc, "cumulative bytes allocated for heap objects")
|
||||
writeMemstat(bw, "gauge", "sys", ms.Sys, "total bytes of memory obtained from the OS")
|
||||
writeMemstat(bw, "counter", "mallocs", ms.Mallocs, "cumulative count of heap objects allocated")
|
||||
writeMemstat(bw, "counter", "frees", ms.Frees, "cumulative count of heap objects freed")
|
||||
writeMemstat(bw, "counter", "num_gc", ms.NumGC, "number of completed GC cycles")
|
||||
writeMemstat(bw, "gauge", "gc_cpu_fraction", ms.GCCPUFraction, "fraction of CPU time used by GC")
|
||||
}))
|
||||
}
|
||||
|
||||
// sortedStructField is metadata about a struct field used both for sorting once
|
||||
// (by structTypeSortedFields) and at serving time (by
|
||||
// foreachExportedStructField).
|
||||
@@ -409,8 +403,7 @@ func structTypeSortedFields(t reflect.Type) []sortedStructField {
|
||||
return v.([]sortedStructField)
|
||||
}
|
||||
fields := make([]sortedStructField, 0, t.NumField())
|
||||
for i, n := 0, t.NumField(); i < n; i++ {
|
||||
sf := t.Field(i)
|
||||
for sf := range t.Fields() {
|
||||
name := sf.Name
|
||||
if v := sf.Tag.Get("json"); v != "" {
|
||||
v, _, _ = strings.Cut(v, ",")
|
||||
@@ -423,7 +416,7 @@ func structTypeSortedFields(t reflect.Type) []sortedStructField {
|
||||
}
|
||||
}
|
||||
fields = append(fields, sortedStructField{
|
||||
Index: i,
|
||||
Index: sf.Index[0],
|
||||
Name: name,
|
||||
SortName: removeTypePrefixes(name),
|
||||
MetricType: sf.Tag.Get("metrictype"),
|
||||
@@ -457,7 +450,7 @@ func foreachExportedStructField(rv reflect.Value, f func(fieldOrJSONName, metric
|
||||
sf := ssf.StructFieldType
|
||||
if ssf.MetricType != "" || sf.Type.Kind() == reflect.Struct {
|
||||
f(ssf.Name, ssf.MetricType, rv.Field(ssf.Index))
|
||||
} else if sf.Type.Kind() == reflect.Ptr && sf.Type.Elem().Kind() == reflect.Struct {
|
||||
} else if sf.Type.Kind() == reflect.Pointer && sf.Type.Elem().Kind() == reflect.Struct {
|
||||
fv := rv.Field(ssf.Index)
|
||||
if !fv.IsNil() {
|
||||
f(ssf.Name, ssf.MetricType, fv.Elem())
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package vcstime provides a file system wrapper that gives embedded
|
||||
// files a meaningful modification time.
|
||||
//
|
||||
// embed.FS stores and serves no ModTime: files report a zero
|
||||
// [fs.FileInfo.ModTime], which disables HTTP caching semantics when
|
||||
// served with [net/http]: no Last-Modified header is sent and
|
||||
// If-Modified-Since requests are never answered with a 304. vcstime
|
||||
// derives a modification time from the vcs.time build setting recorded
|
||||
// by the go command (see "go version -m" or [debug.BuildInfo]),
|
||||
// enabling [http.FS] to provide working cache headers for clean builds.
|
||||
//
|
||||
// FS wraps an [fs.FS] (typically an [embed.FS]) and reports that time as
|
||||
// the modification time of every file that does not have a real
|
||||
// timestamp of its own. Usage:
|
||||
//
|
||||
// //go:embed internal/whatever/*.*
|
||||
// var content embed.FS
|
||||
//
|
||||
// mux.Handle("/", http.FileServer(http.FS(vcstime.FS(content))))
|
||||
//
|
||||
// vcs.time is the commit time of the revision that was built, not the
|
||||
// wall-clock build time: the two coincide for clean CI builds, but a
|
||||
// tree with local modifications yields an older commit time. Binaries
|
||||
// built without VCS stamping (for example with -buildvcs=false) have no
|
||||
// vcs.time, and FS then behaves exactly like the file system it wraps.
|
||||
package vcstime
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
vcsTimeOnce sync.Once
|
||||
vcsTimeVal time.Time
|
||||
)
|
||||
|
||||
// ModTime returns the modification time reported for timestamp-less files
|
||||
// by file systems created with FS: the vcs.time commit time of the
|
||||
// running binary's source revision, or the zero time if the binary
|
||||
// carries no VCS build stamp.
|
||||
func ModTime() time.Time {
|
||||
vcsTimeOnce.Do(func() {
|
||||
bi, ok := debug.ReadBuildInfo()
|
||||
if ok {
|
||||
vcsTimeVal = vcsTime(bi)
|
||||
}
|
||||
})
|
||||
return vcsTimeVal
|
||||
}
|
||||
|
||||
// vcsTime returns the vcs.time build setting from bi as a time, or the
|
||||
// zero time if it is absent or unparseable.
|
||||
func vcsTime(bi *debug.BuildInfo) time.Time {
|
||||
for _, s := range bi.Settings {
|
||||
if s.Key != "vcs.time" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s.Value)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// FS returns an [fs.FS] that serves files from fsys, reporting [ModTime]
|
||||
// as the modification time of any file that has no real timestamp of its
|
||||
// own.
|
||||
//
|
||||
// It is intended for wrapping an [embed.FS], whose files always report a
|
||||
// zero ModTime. Files that already have a real timestamp (for example in
|
||||
// an [fstest.MapFS] or os-backed file system) are passed through
|
||||
// unchanged, so it is always safe to wrap.
|
||||
//
|
||||
// If the binary carries no vcs.time stamp, fsys itself is returned
|
||||
// unchanged.
|
||||
func FS(fsys fs.FS) fs.FS {
|
||||
return New(fsys, ModTime())
|
||||
}
|
||||
|
||||
// New is like FS, but with an explicit modification time to report for
|
||||
// files that have none of their own. If mod is the zero time, fsys is
|
||||
// returned unchanged.
|
||||
//
|
||||
// This exists mostly for tests; prefer FS in production code.
|
||||
func New(fsys fs.FS, mod time.Time) fs.FS {
|
||||
if fsys == nil || mod.IsZero() {
|
||||
return fsys
|
||||
}
|
||||
return &modFS{fsys: fsys, mod: mod}
|
||||
}
|
||||
|
||||
// modFS is an fs.FS wrapper that stamps timestamp-less files with a fixed
|
||||
// modification time.
|
||||
type modFS struct {
|
||||
fsys fs.FS
|
||||
mod time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.ReadDirFS = (*modFS)(nil)
|
||||
_ fs.ReadFileFS = (*modFS)(nil)
|
||||
)
|
||||
|
||||
func (m *modFS) Open(name string) (fs.File, error) {
|
||||
f, err := m.fsys.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &modFile{File: f, name: name, mod: m.mod}, nil
|
||||
}
|
||||
|
||||
func (m *modFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
des, err := fs.ReadDir(m.fsys, name)
|
||||
return stampDirEntries(des, m.mod), err
|
||||
}
|
||||
|
||||
func (m *modFS) ReadFile(name string) ([]byte, error) {
|
||||
return fs.ReadFile(m.fsys, name)
|
||||
}
|
||||
|
||||
// modFile is an fs.File wrapper that reports a stamped modification time
|
||||
// from Stat. Read, Close, Seek and ReadDir are forwarded to the
|
||||
// underlying file when it supports them, so that http.FS and other
|
||||
// consumers keep working (http.FS type-asserts files for io.Seeker and
|
||||
// fs.ReadDirFile).
|
||||
type modFile struct {
|
||||
fs.File
|
||||
name string
|
||||
mod time.Time
|
||||
}
|
||||
|
||||
var _ fs.ReadDirFile = (*modFile)(nil)
|
||||
|
||||
func (f *modFile) Stat() (fs.FileInfo, error) {
|
||||
info, err := f.File.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stampFileInfo(info, f.mod), nil
|
||||
}
|
||||
|
||||
func (f *modFile) Seek(offset int64, whence int) (int64, error) {
|
||||
if s, ok := f.File.(io.Seeker); ok {
|
||||
return s.Seek(offset, whence)
|
||||
}
|
||||
return 0, &fs.PathError{Op: "seek", Path: f.name, Err: fs.ErrInvalid}
|
||||
}
|
||||
|
||||
func (f *modFile) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
rdf, ok := f.File.(fs.ReadDirFile)
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid}
|
||||
}
|
||||
des, err := rdf.ReadDir(n)
|
||||
return stampDirEntries(des, f.mod), err
|
||||
}
|
||||
|
||||
// stampFileInfo returns info unchanged if it already has a modification
|
||||
// time or there is nothing to stamp it with, and otherwise a FileInfo
|
||||
// that reports mod instead.
|
||||
func stampFileInfo(info fs.FileInfo, mod time.Time) fs.FileInfo {
|
||||
if mod.IsZero() || !info.ModTime().IsZero() {
|
||||
return info
|
||||
}
|
||||
return modFileInfo{FileInfo: info, mod: mod}
|
||||
}
|
||||
|
||||
func stampDirEntries(des []fs.DirEntry, mod time.Time) []fs.DirEntry {
|
||||
if mod.IsZero() {
|
||||
return des
|
||||
}
|
||||
for i, de := range des {
|
||||
info, err := de.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().IsZero() {
|
||||
des[i] = fs.FileInfoToDirEntry(modFileInfo{FileInfo: info, mod: mod})
|
||||
}
|
||||
}
|
||||
return des
|
||||
}
|
||||
|
||||
// modFileInfo is an fs.FileInfo that reports mod as its modification
|
||||
// time, delegating everything else.
|
||||
type modFileInfo struct {
|
||||
fs.FileInfo
|
||||
mod time.Time
|
||||
}
|
||||
|
||||
func (i modFileInfo) ModTime() time.Time { return i.mod }
|
||||
Reference in new issue
Block a user