Browse Source

feat(stupgrades): filter returned releases per compatibility

Jakob Borg 1 year ago
parent
commit
fe01b396ba
3 changed files with 245 additions and 32 deletions
  1. 211 31
      cmd/infra/stupgrades/main.go
  2. 30 0
      cmd/infra/stupgrades/metrics.go
  3. 4 1
      lib/upgrade/upgrade_common.go

+ 211 - 31
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(&params)
+	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: slog.LevelInfo,
+	})))
+
 	if err := server(&params); 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
+		}
+	}
+}

+ 30 - 0
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"})
+)

+ 4 - 1
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"`