mirror of
https://github.com/octoleo/syncthing.git
synced 2025-01-02 22:50:18 +00:00
feat(stupgrades): filter returned releases per compatibility
This commit is contained in:
parent
3583949706
commit
fe01b396ba
@ -7,15 +7,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alecthomas/kong"
|
"github.com/alecthomas/kong"
|
||||||
@ -27,7 +31,7 @@ import (
|
|||||||
|
|
||||||
type cli struct {
|
type cli struct {
|
||||||
Listen string `default:":8080" help:"Listen address"`
|
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"`
|
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"`
|
Forward []string `short:"f" help:"Forwarded pages, format: /path->https://example/com/url"`
|
||||||
CacheTime time.Duration `default:"15m" help:"Cache time"`
|
CacheTime time.Duration `default:"15m" help:"Cache time"`
|
||||||
@ -36,6 +40,10 @@ type cli struct {
|
|||||||
func main() {
|
func main() {
|
||||||
var params cli
|
var params cli
|
||||||
kong.Parse(¶ms)
|
kong.Parse(¶ms)
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
})))
|
||||||
|
|
||||||
if err := server(¶ms); err != nil {
|
if err := server(¶ms); err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
fmt.Printf("Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -46,24 +54,47 @@ func server(params *cli) error {
|
|||||||
if params.MetricsListen != "" {
|
if params.MetricsListen != "" {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/metrics", promhttp.Handler())
|
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() {
|
go func() {
|
||||||
log.Println("Listening for metrics on", params.MetricsListen)
|
if err := http.Serve(metricsListen, mux); err != nil {
|
||||||
if err := http.ListenAndServe(params.MetricsListen, mux); err != nil {
|
slog.Warn("Metrics server returned", "error", err)
|
||||||
log.Fatalf("Failed to start metrics server: %v", 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 := 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 {
|
for _, fwd := range params.Forward {
|
||||||
path, url, ok := strings.Cut(fwd, "->")
|
path, url, ok := strings.Cut(fwd, "->")
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("invalid forward: %q", fwd)
|
return fmt.Errorf("invalid forward: %q", fwd)
|
||||||
}
|
}
|
||||||
log.Println("Forwarding", path, "to", url)
|
slog.Info("Forwarding", "from", path, "to", url)
|
||||||
mux.Handle(path, httpcache.SinglePath(&proxy{url: url}, params.CacheTime))
|
name := strings.ReplaceAll(path, "/", "_")
|
||||||
|
mux.Handle(path, httpcache.SinglePath(&proxy{name: name, url: url}, params.CacheTime))
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@ -73,60 +104,76 @@ func server(params *cli) error {
|
|||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
srv.SetKeepAlivesEnabled(false)
|
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 {
|
type githubReleases struct {
|
||||||
url string
|
cache *cachedReleases
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *githubReleases) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
func (p *githubReleases) servePing(w http.ResponseWriter, req *http.Request) {
|
||||||
log.Println("Fetching", p.url)
|
rels := p.cache.Releases()
|
||||||
rels := upgrade.FetchLatestReleases(p.url, "")
|
|
||||||
if rels == nil {
|
if len(rels) == 0 {
|
||||||
http.Error(w, "no releases", http.StatusInternalServerError)
|
http.Error(w, "No releases available", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Sort(upgrade.SortByRelease(rels))
|
w.Header().Set("Syncthing-Num-Releases", strconv.Itoa(len(rels)))
|
||||||
rels = filterForLatest(rels)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
// Move the URL used for browser downloads to the URL field, and remove
|
func (p *githubReleases) serveReleases(w http.ResponseWriter, req *http.Request) {
|
||||||
// the browser URL field. This avoids going via the GitHub API for
|
rels := p.cache.Releases()
|
||||||
// downloads, since Syncthing uses the URL field.
|
|
||||||
for _, rel := range rels {
|
ua := req.Header.Get("User-Agent")
|
||||||
for j, asset := range rel.Assets {
|
osv := req.Header.Get("Syncthing-Os-Version")
|
||||||
rel.Assets[j].URL = asset.BrowserURL
|
if ua != "" && osv != "" {
|
||||||
rel.Assets[j].BrowserURL = ""
|
// 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)
|
rels = filterForLatest(rels)
|
||||||
_ = json.NewEncoder(buf).Encode(rels)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
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 {
|
type proxy struct {
|
||||||
url string
|
name string
|
||||||
|
url string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
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)
|
req, err := http.NewRequestWithContext(req.Context(), http.MethodGet, p.url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
metricHTTPRequests.WithLabelValues(p.name, "error").Inc()
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
metricHTTPRequests.WithLabelValues(p.name, "error").Inc()
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
metricHTTPRequests.WithLabelValues(p.name, "success").Inc()
|
||||||
|
|
||||||
ct := resp.Header.Get("Content-Type")
|
ct := resp.Header.Get("Content-Type")
|
||||||
w.Header().Set("Content-Type", ct)
|
w.Header().Set("Content-Type", ct)
|
||||||
@ -169,3 +216,136 @@ func filterForLatest(rels []upgrade.Release) []upgrade.Release {
|
|||||||
}
|
}
|
||||||
return filtered
|
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
cmd/infra/stupgrades/metrics.go
Normal file
30
cmd/infra/stupgrades/metrics.go
Normal file
@ -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"})
|
||||||
|
)
|
@ -27,6 +27,9 @@ type Release struct {
|
|||||||
// The HTML URL is needed for human readable links in the output created
|
// The HTML URL is needed for human readable links in the output created
|
||||||
// by cmd/infra/stupgrades.
|
// by cmd/infra/stupgrades.
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
|
||||||
|
// The compatibility information is included with each current release.
|
||||||
|
Compatibility *ReleaseCompatibility `json:"compatibility,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Asset struct {
|
type Asset struct {
|
||||||
@ -39,7 +42,7 @@ type Asset struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseCompatibility defines the structure of compat.json, which is
|
// ReleaseCompatibility defines the structure of compat.json, which is
|
||||||
// included with each elease.
|
// included with each release.
|
||||||
type ReleaseCompatibility struct {
|
type ReleaseCompatibility struct {
|
||||||
Runtime string `json:"runtime,omitempty"`
|
Runtime string `json:"runtime,omitempty"`
|
||||||
Requirements map[string]string `json:"requirements,omitempty"`
|
Requirements map[string]string `json:"requirements,omitempty"`
|
||||||
|
Loading…
Reference in New Issue
Block a user