From fe01b396bac1e3b863fe80dd35b21c1959fb19c0 Mon Sep 17 00:00:00 2001 From: Jakob Borg Date: Tue, 24 Sep 2024 10:07:36 +0200 Subject: [PATCH] feat(stupgrades): filter returned releases per compatibility --- cmd/infra/stupgrades/main.go | 242 ++++++++++++++++++++++++++++---- cmd/infra/stupgrades/metrics.go | 30 ++++ lib/upgrade/upgrade_common.go | 5 +- 3 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 cmd/infra/stupgrades/metrics.go diff --git a/cmd/infra/stupgrades/main.go b/cmd/infra/stupgrades/main.go index e3b118fa6..49e4c9857 100644 --- a/cmd/infra/stupgrades/main.go +++ b/cmd/infra/stupgrades/main.go @@ -7,15 +7,19 @@ package main import ( - "bytes" + "context" "encoding/json" "fmt" "io" - "log" + "log/slog" + "net" "net/http" "os" + "regexp" "sort" + "strconv" "strings" + "sync" "time" "github.com/alecthomas/kong" @@ -27,7 +31,7 @@ import ( type cli struct { Listen string `default:":8080" help:"Listen address"` - MetricsListen string `default:":8081" help:"Listen address for metrics"` + MetricsListen string `default:":8082" help:"Listen address for metrics"` URL string `short:"u" default:"https://api.github.com/repos/syncthing/syncthing/releases?per_page=25" help:"GitHub releases url"` Forward []string `short:"f" help:"Forwarded pages, format: /path->https://example/com/url"` CacheTime time.Duration `default:"15m" help:"Cache time"` @@ -36,6 +40,10 @@ type cli struct { func main() { var params cli kong.Parse(¶ms) + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + if err := server(¶ms); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) @@ -46,24 +54,47 @@ func server(params *cli) error { if params.MetricsListen != "" { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) + metricsListen, err := net.Listen("tcp", params.MetricsListen) + if err != nil { + return fmt.Errorf("metrics: %w", err) + } + slog.Info("Metrics listener started", "addr", params.MetricsListen) go func() { - log.Println("Listening for metrics on", params.MetricsListen) - if err := http.ListenAndServe(params.MetricsListen, mux); err != nil { - log.Fatalf("Failed to start metrics server: %v", err) + if err := http.Serve(metricsListen, mux); err != nil { + slog.Warn("Metrics server returned", "error", err) } }() } + cache := &cachedReleases{url: params.URL} + if err := cache.Update(context.Background()); err != nil { + return fmt.Errorf("initial cache update: %w", err) + } else { + slog.Info("Initial cache update done") + } + + go func() { + for range time.NewTicker(params.CacheTime).C { + slog.Info("Refreshing cached releases", "url", params.URL) + if err := cache.Update(context.Background()); err != nil { + slog.Error("Failed to refresh cached releases", "url", params.URL, "error", err) + } + } + }() + + ghRels := &githubReleases{cache: cache} mux := http.NewServeMux() - mux.Handle("/meta.json", httpcache.SinglePath(&githubReleases{url: params.URL}, params.CacheTime)) + mux.HandleFunc("/ping", ghRels.servePing) + mux.HandleFunc("/meta.json", ghRels.serveReleases) for _, fwd := range params.Forward { path, url, ok := strings.Cut(fwd, "->") if !ok { return fmt.Errorf("invalid forward: %q", fwd) } - log.Println("Forwarding", path, "to", url) - mux.Handle(path, httpcache.SinglePath(&proxy{url: url}, params.CacheTime)) + slog.Info("Forwarding", "from", path, "to", url) + name := strings.ReplaceAll(path, "/", "_") + mux.Handle(path, httpcache.SinglePath(&proxy{name: name, url: url}, params.CacheTime)) } srv := &http.Server{ @@ -73,60 +104,76 @@ func server(params *cli) error { WriteTimeout: 10 * time.Second, } srv.SetKeepAlivesEnabled(false) - return srv.ListenAndServe() + + srvListener, err := net.Listen("tcp", params.Listen) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + slog.Info("Main listener started", "addr", params.Listen) + + return srv.Serve(srvListener) } type githubReleases struct { - url string + cache *cachedReleases } -func (p *githubReleases) ServeHTTP(w http.ResponseWriter, _ *http.Request) { - log.Println("Fetching", p.url) - rels := upgrade.FetchLatestReleases(p.url, "") - if rels == nil { - http.Error(w, "no releases", http.StatusInternalServerError) +func (p *githubReleases) servePing(w http.ResponseWriter, req *http.Request) { + rels := p.cache.Releases() + + if len(rels) == 0 { + http.Error(w, "No releases available", http.StatusServiceUnavailable) return } - sort.Sort(upgrade.SortByRelease(rels)) - rels = filterForLatest(rels) + w.Header().Set("Syncthing-Num-Releases", strconv.Itoa(len(rels))) + w.WriteHeader(http.StatusOK) +} - // Move the URL used for browser downloads to the URL field, and remove - // the browser URL field. This avoids going via the GitHub API for - // downloads, since Syncthing uses the URL field. - for _, rel := range rels { - for j, asset := range rel.Assets { - rel.Assets[j].URL = asset.BrowserURL - rel.Assets[j].BrowserURL = "" - } +func (p *githubReleases) serveReleases(w http.ResponseWriter, req *http.Request) { + rels := p.cache.Releases() + + ua := req.Header.Get("User-Agent") + osv := req.Header.Get("Syncthing-Os-Version") + if ua != "" && osv != "" { + // We should determine the compatibility of the releases. + rels = filterForCompabitility(rels, ua, osv) + } else { + metricFilterCalls.WithLabelValues("no-ua-or-osversion").Inc() } - buf := new(bytes.Buffer) - _ = json.NewEncoder(buf).Encode(rels) + rels = filterForLatest(rels) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET") - w.Write(buf.Bytes()) + w.Header().Set("Cache-Control", "public, max-age=900") + w.Header().Set("Vary", "User-Agent, Syncthing-Os-Version") + _ = json.NewEncoder(w).Encode(rels) + + metricUpgradeChecks.Inc() } type proxy struct { - url string + name string + url string } func (p *proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { - log.Println("Fetching", p.url) req, err := http.NewRequestWithContext(req.Context(), http.MethodGet, p.url, nil) if err != nil { + metricHTTPRequests.WithLabelValues(p.name, "error").Inc() http.Error(w, err.Error(), http.StatusInternalServerError) return } resp, err := http.DefaultClient.Do(req) if err != nil { + metricHTTPRequests.WithLabelValues(p.name, "error").Inc() http.Error(w, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() + metricHTTPRequests.WithLabelValues(p.name, "success").Inc() ct := resp.Header.Get("Content-Type") w.Header().Set("Content-Type", ct) @@ -169,3 +216,136 @@ func filterForLatest(rels []upgrade.Release) []upgrade.Release { } return filtered } + +var userAgentOSArchExp = regexp.MustCompile(`^syncthing.*\(.+ (\w+)-(\w+)\)$`) + +func filterForCompabitility(rels []upgrade.Release, ua, osv string) []upgrade.Release { + osArch := userAgentOSArchExp.FindStringSubmatch(ua) + if len(osArch) != 3 { + metricFilterCalls.WithLabelValues("bad-os-arch").Inc() + return rels + } + os := osArch[1] + + filtered := rels[:0] + for _, rel := range rels { + if rel.Compatibility == nil { + // No requirements means it's compatible with everything. + filtered = append(filtered, rel) + continue + } + + req, ok := rel.Compatibility.Requirements[os] + if !ok { + // No entry for the current OS means it's compatible. + filtered = append(filtered, rel) + continue + } + + if upgrade.CompareVersions(osv, req) >= 0 { + filtered = append(filtered, rel) + continue + } + } + + if len(filtered) != len(rels) { + metricFilterCalls.WithLabelValues("filtered").Inc() + } else { + metricFilterCalls.WithLabelValues("unchanged").Inc() + } + + return filtered +} + +type cachedReleases struct { + url string + mut sync.RWMutex + current []upgrade.Release +} + +func (c *cachedReleases) Releases() []upgrade.Release { + c.mut.RLock() + defer c.mut.RUnlock() + return c.current +} + +func (c *cachedReleases) Update(ctx context.Context) error { + rels, err := fetchGithubReleases(ctx, c.url) + if err != nil { + return err + } + c.mut.Lock() + c.current = rels + c.mut.Unlock() + return nil +} + +func fetchGithubReleases(ctx context.Context, url string) ([]upgrade.Release, error) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil) + if err != nil { + metricHTTPRequests.WithLabelValues("github-releases", "error").Inc() + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + metricHTTPRequests.WithLabelValues("github-releases", "error").Inc() + return nil, err + } + defer resp.Body.Close() + var rels []upgrade.Release + if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil { + metricHTTPRequests.WithLabelValues("github-releases", "error").Inc() + return nil, err + } + metricHTTPRequests.WithLabelValues("github-releases", "success").Inc() + + // Move the URL used for browser downloads to the URL field, and remove + // the browser URL field. This avoids going via the GitHub API for + // downloads, since Syncthing uses the URL field. + for _, rel := range rels { + for j, asset := range rel.Assets { + rel.Assets[j].URL = asset.BrowserURL + rel.Assets[j].BrowserURL = "" + } + } + + addReleaseCompatibility(ctx, rels) + + sort.Sort(upgrade.SortByRelease(rels)) + return rels, nil +} + +func addReleaseCompatibility(ctx context.Context, rels []upgrade.Release) { + for i := range rels { + rel := &rels[i] + for i, asset := range rel.Assets { + if asset.Name != "compat.json" { + continue + } + + // Load compat.json into the Compatibility field + req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil) + if err != nil { + metricHTTPRequests.WithLabelValues("compat-json", "error").Inc() + break + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + metricHTTPRequests.WithLabelValues("compat-json", "error").Inc() + break + } + if resp.StatusCode != http.StatusOK { + metricHTTPRequests.WithLabelValues("compat-json", "error").Inc() + resp.Body.Close() + break + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 10<<10)).Decode(&rel.Compatibility) + metricHTTPRequests.WithLabelValues("compat-json", "success").Inc() + resp.Body.Close() + + // Remove compat.json from the asset list since it's been processed + rel.Assets = append(rel.Assets[:i], rel.Assets[i+1:]...) + break + } + } +} diff --git a/cmd/infra/stupgrades/metrics.go b/cmd/infra/stupgrades/metrics.go new file mode 100644 index 000000000..b74dcc61b --- /dev/null +++ b/cmd/infra/stupgrades/metrics.go @@ -0,0 +1,30 @@ +// Copyright (C) 2024 The Syncthing Authors. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +package main + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + metricUpgradeChecks = promauto.NewCounter(prometheus.CounterOpts{ + Namespace: "syncthing", + Subsystem: "upgrade", + Name: "metadata_requests", + }) + metricFilterCalls = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "syncthing", + Subsystem: "upgrade", + Name: "filter_calls", + }, []string{"result"}) + metricHTTPRequests = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "syncthing", + Subsystem: "upgrade", + Name: "http_requests", + }, []string{"target", "result"}) +) diff --git a/lib/upgrade/upgrade_common.go b/lib/upgrade/upgrade_common.go index 053e8fb6a..8d1479e5e 100644 --- a/lib/upgrade/upgrade_common.go +++ b/lib/upgrade/upgrade_common.go @@ -27,6 +27,9 @@ type Release struct { // The HTML URL is needed for human readable links in the output created // by cmd/infra/stupgrades. HTMLURL string `json:"html_url"` + + // The compatibility information is included with each current release. + Compatibility *ReleaseCompatibility `json:"compatibility,omitempty"` } type Asset struct { @@ -39,7 +42,7 @@ type Asset struct { } // ReleaseCompatibility defines the structure of compat.json, which is -// included with each elease. +// included with each release. type ReleaseCompatibility struct { Runtime string `json:"runtime,omitempty"` Requirements map[string]string `json:"requirements,omitempty"`