feat: add bandwidth testing to runner capabilities
All checks were successful
CI / build-and-test (push) Successful in 8s

- Add BandwidthManager for periodic bandwidth tests (hourly)
- Test download speed and latency against registered Gitea server
- Include bandwidth in runner capabilities JSON
- Add FormatBandwidth helper for display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
GitCaddy 2026-01-11 07:38:49 +00:00
parent 2c66de4df2
commit ab382dc256
3 changed files with 240 additions and 3 deletions

View File

@ -37,8 +37,13 @@ const (
DiskSpaceCriticalThreshold = 95.0
// CapabilitiesUpdateInterval is how often to update capabilities (including disk space)
CapabilitiesUpdateInterval = 5 * time.Minute
// BandwidthTestInterval is how often to run bandwidth tests (hourly)
BandwidthTestInterval = 1 * time.Hour
)
// Global bandwidth manager - accessible for triggering manual tests
var bandwidthManager *envcheck.BandwidthManager
func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadDefault(*configFile)
@ -152,7 +157,15 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
dockerHost = dh
}
}
// Initialize bandwidth manager with the Gitea server address
bandwidthManager = envcheck.NewBandwidthManager(reg.Address, BandwidthTestInterval)
bandwidthManager.Start(ctx)
log.Infof("bandwidth manager started, testing against: %s", reg.Address)
capabilities := envcheck.DetectCapabilities(ctx, dockerHost)
// Include initial bandwidth result if available
capabilities.Bandwidth = bandwidthManager.GetLastResult()
capabilitiesJson := capabilities.ToJSON()
log.Infof("detected capabilities: %s", capabilitiesJson)
@ -225,7 +238,7 @@ func checkDiskSpaceWarnings(capabilities *envcheck.RunnerCapabilities) {
}
}
// periodicCapabilitiesUpdate periodically updates capabilities including disk space
// periodicCapabilitiesUpdate periodically updates capabilities including disk space and bandwidth
func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNames []string, dockerHost string) {
ticker := time.NewTicker(CapabilitiesUpdateInterval)
defer ticker.Stop()
@ -234,10 +247,19 @@ func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNa
select {
case <-ctx.Done():
log.Debug("stopping periodic capabilities update")
if bandwidthManager != nil {
bandwidthManager.Stop()
}
return
case <-ticker.C:
// Detect updated capabilities (disk space changes over time)
capabilities := envcheck.DetectCapabilities(ctx, dockerHost)
// Include latest bandwidth result
if bandwidthManager != nil {
capabilities.Bandwidth = bandwidthManager.GetLastResult()
}
capabilitiesJson := capabilities.ToJSON()
// Check for disk space warnings
@ -248,9 +270,14 @@ func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNa
if err != nil {
log.WithError(err).Debug("failed to update capabilities")
} else {
log.Debugf("capabilities updated: disk %.1f%% used, %.2f GB free",
bandwidthInfo := ""
if capabilities.Bandwidth != nil {
bandwidthInfo = fmt.Sprintf(", bandwidth: %.1f Mbps", capabilities.Bandwidth.DownloadMbps)
}
log.Debugf("capabilities updated: disk %.1f%% used, %.2f GB free%s",
capabilities.Disk.UsedPercent,
float64(capabilities.Disk.Free)/(1024*1024*1024))
float64(capabilities.Disk.Free)/(1024*1024*1024),
bandwidthInfo)
}
}
}

View File

@ -0,0 +1,209 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package envcheck
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// BandwidthInfo holds network bandwidth test results
type BandwidthInfo struct {
DownloadMbps float64 `json:"download_mbps"`
UploadMbps float64 `json:"upload_mbps,omitempty"`
Latency float64 `json:"latency_ms,omitempty"`
TestedAt time.Time `json:"tested_at"`
}
// BandwidthManager handles periodic bandwidth testing
type BandwidthManager struct {
serverURL string
lastResult *BandwidthInfo
mu sync.RWMutex
testInterval time.Duration
stopChan chan struct{}
}
// NewBandwidthManager creates a new bandwidth manager
func NewBandwidthManager(serverURL string, testInterval time.Duration) *BandwidthManager {
return &BandwidthManager{
serverURL: serverURL,
testInterval: testInterval,
stopChan: make(chan struct{}),
}
}
// Start begins periodic bandwidth testing
func (bm *BandwidthManager) Start(ctx context.Context) {
// Run initial test
bm.RunTest(ctx)
// Start periodic testing
go func() {
ticker := time.NewTicker(bm.testInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
bm.RunTest(ctx)
case <-bm.stopChan:
return
case <-ctx.Done():
return
}
}
}()
}
// Stop stops the periodic testing
func (bm *BandwidthManager) Stop() {
close(bm.stopChan)
}
// RunTest runs a bandwidth test and stores the result
func (bm *BandwidthManager) RunTest(ctx context.Context) *BandwidthInfo {
result := TestBandwidth(ctx, bm.serverURL)
bm.mu.Lock()
bm.lastResult = result
bm.mu.Unlock()
return result
}
// GetLastResult returns the most recent bandwidth test result
func (bm *BandwidthManager) GetLastResult() *BandwidthInfo {
bm.mu.RLock()
defer bm.mu.RUnlock()
return bm.lastResult
}
// TestBandwidth tests network bandwidth to the Gitea server
func TestBandwidth(ctx context.Context, serverURL string) *BandwidthInfo {
if serverURL == "" {
return nil
}
info := &BandwidthInfo{
TestedAt: time.Now(),
}
// Test latency first
info.Latency = testLatency(ctx, serverURL)
// Test download speed
info.DownloadMbps = testDownloadSpeed(ctx, serverURL)
return info
}
func testLatency(ctx context.Context, serverURL string) float64 {
client := &http.Client{
Timeout: 10 * time.Second,
}
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, "HEAD", serverURL, nil)
if err != nil {
return 0
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return 0
}
resp.Body.Close()
latency := time.Since(start).Seconds() * 1000 // Convert to ms
return float64(int(latency*100)) / 100 // Round to 2 decimals
}
func testDownloadSpeed(ctx context.Context, serverURL string) float64 {
// Try multiple endpoints to accumulate ~1MB of data
endpoints := []string{
"/assets/css/index.css",
"/assets/js/index.js",
"/assets/img/logo.svg",
"/assets/img/logo.png",
"/",
}
client := &http.Client{
Timeout: 30 * time.Second,
}
var totalBytes int64
var totalDuration time.Duration
targetBytes := int64(1024 * 1024) // 1MB target
maxAttempts := 10 // Limit iterations
for attempt := 0; attempt < maxAttempts && totalBytes < targetBytes; attempt++ {
for _, endpoint := range endpoints {
if totalBytes >= targetBytes {
break
}
url := serverURL + endpoint
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil)
if err != nil {
cancel()
continue
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
cancel()
continue
}
n, _ := io.Copy(io.Discard, resp.Body)
resp.Body.Close()
cancel()
duration := time.Since(start)
if n > 0 {
totalBytes += n
totalDuration += duration
}
}
}
if totalBytes == 0 || totalDuration == 0 {
return 0
}
// Calculate speed in Mbps
seconds := totalDuration.Seconds()
if seconds == 0 {
return 0
}
bytesPerSecond := float64(totalBytes) / seconds
mbps := (bytesPerSecond * 8) / (1024 * 1024)
return float64(int(mbps*100)) / 100
}
// FormatBandwidth formats bandwidth for display
func FormatBandwidth(mbps float64) string {
if mbps == 0 {
return "Unknown"
}
if mbps >= 1000 {
return fmt.Sprintf("%.1f Gbps", mbps/1000)
}
return fmt.Sprintf("%.1f Mbps", mbps)
}

View File

@ -35,6 +35,7 @@ type RunnerCapabilities struct {
Features *CapabilityFeatures `json:"features,omitempty"`
Limitations []string `json:"limitations,omitempty"`
Disk *DiskInfo `json:"disk,omitempty"`
Bandwidth *BandwidthInfo `json:"bandwidth,omitempty"`
}
// CapabilityFeatures represents feature support flags