From fb69ce29e01c783c3a01158d03f6896dc59cf364 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 23 Aug 2026 14:21:56 +0200 Subject: [PATCH] Bump Golang to 1.27.0 --- docker-compose.ci.yml | 6 +- .../content/docs/appendix/PROJECT-PATTERNS.md | 2 +- docs/content/docs/guides/DEPLOYMENT.md | 168 +++++++++--------- examples/navigation-station-demo/go.mod | 4 +- examples/preset-management/go.mod | 4 +- go.mod | 2 +- 6 files changed, 93 insertions(+), 93 deletions(-) diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 3af68d0..b83ed43 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -35,7 +35,7 @@ services: start_period: 3s spotify-mock: - image: golang:1.26.6-alpine + image: golang:1.27.0-alpine container_name: spotify-mock working_dir: /app volumes: @@ -53,7 +53,7 @@ services: start_period: 3s amazon-mock: - image: golang:1.26.6-alpine + image: golang:1.27.0-alpine container_name: amazon-mock working_dir: /app volumes: @@ -71,7 +71,7 @@ services: start_period: 3s tunein-mock: - image: golang:1.26.6-alpine + image: golang:1.27.0-alpine container_name: tunein-mock working_dir: /app volumes: diff --git a/docs/content/docs/appendix/PROJECT-PATTERNS.md b/docs/content/docs/appendix/PROJECT-PATTERNS.md index aae872c..2d939a7 100644 --- a/docs/content/docs/appendix/PROJECT-PATTERNS.md +++ b/docs/content/docs/appendix/PROJECT-PATTERNS.md @@ -566,7 +566,7 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) { ```dockerfile # test/docker/Dockerfile -FROM golang:1.25-alpine +FROM golang:1.27.0-alpine WORKDIR /app COPY . . diff --git a/docs/content/docs/guides/DEPLOYMENT.md b/docs/content/docs/guides/DEPLOYMENT.md index 09d7592..1723af3 100644 --- a/docs/content/docs/guides/DEPLOYMENT.md +++ b/docs/content/docs/guides/DEPLOYMENT.md @@ -115,25 +115,25 @@ type ProductionSoundTouchService struct { type Config struct { // Server settings ListenAddr string `env:"LISTEN_ADDR" default:":8080"` - + // SoundTouch settings DeviceHosts []string `env:"DEVICE_HOSTS" separator:","` DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"` RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"` MaxRetries int `env:"MAX_RETRIES" default:"3"` - + // Connection pool MaxConnections int `env:"MAX_CONNECTIONS" default:"10"` IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"` - + // Monitoring MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"` HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"` - + // Logging LogLevel string `env:"LOG_LEVEL" default:"info"` LogFormat string `env:"LOG_FORMAT" default:"json"` - + // Security EnableTLS bool `env:"ENABLE_TLS" default:"false"` TLSCertFile string `env:"TLS_CERT_FILE"` @@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) { if err := env.Parse(cfg); err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } - + return cfg, cfg.Validate() } @@ -153,15 +153,15 @@ func (c *Config) Validate() error { if len(c.DeviceHosts) == 0 { return fmt.Errorf("at least one device host must be specified") } - + if c.RequestTimeout < time.Second { return fmt.Errorf("request timeout must be at least 1 second") } - + if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") { return fmt.Errorf("TLS cert and key files required when TLS is enabled") } - + return nil } ``` @@ -191,7 +191,7 @@ pool: monitoring: metrics_enabled: true health_check_interval: "30s" - + logging: level: "info" format: "json" @@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) { if err != nil { return nil, err } - + var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } - + return &cfg, cfg.Validate() } ``` @@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) { type SecureNetworkConfig struct { // Allowed source IP ranges AllowedCIDRs []string - + // Rate limiting RateLimit int RateLimitWindow time.Duration - + // TLS configuration TLSConfig *tls.Config - + // Timeouts for security ReadTimeout time.Duration WriteTimeout time.Duration @@ -240,7 +240,7 @@ type SecureNetworkConfig struct { func NewSecureServer(config SecureNetworkConfig) *http.Server { mux := http.NewServeMux() - + // Add middleware handler := applyMiddleware(mux, corsMiddleware(), @@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server { loggingMiddleware(), metricsMiddleware(), ) - + return &http.Server{ Handler: handler, TLSConfig: config.TLSConfig, @@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error { if err := validate.Struct(r); err != nil { return fmt.Errorf("validation failed: %w", err) } - + // Additional business logic validation if r.Action == "volume" && r.Volume == nil { return fmt.Errorf("volume value required for volume action") } - + return nil } ``` @@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) { if err != nil { return nil, err } - + tlsKey, err := os.ReadFile("/etc/secrets/tls.key") if err != nil { return nil, err } - + return &SecretsConfig{ TLSCert: string(tlsCert), TLSKey: string(tlsKey), @@ -335,21 +335,21 @@ type Logger struct { func NewLogger(level, format, component string) (*Logger, error) { logger := logrus.New() - + // Set level logLevel, err := logrus.ParseLevel(level) if err != nil { return nil, err } logger.SetLevel(logLevel) - + // Set format if format == "json" { logger.SetFormatter(&logrus.JSONFormatter{ TimestampFormat: time.RFC3339, }) } - + return &Logger{ Logger: logger, component: component, @@ -376,15 +376,15 @@ type Metrics struct { RequestsTotal prometheus.CounterVec RequestDuration prometheus.HistogramVec RequestsInFlight prometheus.GaugeVec - + // Device metrics DevicesConnected prometheus.Gauge DeviceHealth prometheus.GaugeVec WebSocketConnections prometheus.Gauge - + // Error metrics ErrorsTotal prometheus.CounterVec - + // Business metrics VolumeChanges prometheus.CounterVec SourceChanges prometheus.CounterVec @@ -400,7 +400,7 @@ func NewMetrics() *Metrics { }, []string{"method", "endpoint", "status"}, ), - + RequestDuration: *prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: "soundtouch_request_duration_seconds", @@ -409,14 +409,14 @@ func NewMetrics() *Metrics { }, []string{"method", "endpoint"}, ), - + DevicesConnected: prometheus.NewGauge( prometheus.GaugeOpts{ Name: "soundtouch_devices_connected", Help: "Number of connected devices", }, ), - + DeviceHealth: *prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "soundtouch_device_health", @@ -425,7 +425,7 @@ func NewMetrics() *Metrics { []string{"device_id", "device_name"}, ), } - + // Register metrics prometheus.MustRegister( m.RequestsTotal, @@ -433,7 +433,7 @@ func NewMetrics() *Metrics { m.DevicesConnected, m.DeviceHealth, ) - + return m } @@ -457,7 +457,7 @@ type HealthChecker struct { func (hc *HealthChecker) Start(ctx context.Context) { ticker := time.NewTicker(hc.interval) defer ticker.Stop() - + for { select { case <-ctx.Done(): @@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) { func (hc *HealthChecker) checkAllDevices() { var wg sync.WaitGroup - + for deviceID, device := range hc.manager.devices { wg.Add(1) go func(id string, dev *DeviceInfo) { @@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() { hc.checkDevice(id, dev) }(deviceID, device) } - + wg.Wait() } func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) { ctx, cancel := context.WithTimeout(context.Background(), hc.timeout) defer cancel() - + start := time.Now() err := device.Client.Ping() duration := time.Since(start) - + if err != nil { device.Status = DeviceStatusUnhealthy hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0) @@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { healthy := 0 total := 0 - + for _, device := range hc.manager.devices { total++ if device.Status == DeviceStatusHealthy { healthy++ } } - + status := map[string]interface{}{ "status": "ok", "devices": map[string]interface{}{ @@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc { }, "timestamp": time.Now().UTC(), } - + w.Header().Set("Content-Type", "application/json") - + if healthy < total { w.WriteHeader(http.StatusServiceUnavailable) status["status"] = "degraded" } - + json.NewEncoder(w).Encode(status) } } @@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne maxActive: maxActive, idleTimeout: idleTimeout, } - + // Start cleanup goroutine go cp.cleanup() - + return cp } func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) { key := fmt.Sprintf("%s:%d", host, port) - + // Check if connection exists and is valid if val, ok := cp.clients.Load(key); ok { conn := val.(*pooledConnection) @@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) { // Connection expired, remove it cp.clients.Delete(key) } - + // Check active connection limit if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) { return nil, fmt.Errorf("connection pool exhausted") } - + // Create new connection config := client.ClientConfig{ Host: host, Port: port, Timeout: 15 * time.Second, } - + newClient := client.NewClient(config) - + // Test connection if err := newClient.Ping(); err != nil { return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err) } - + conn := &pooledConnection{ client: newClient, lastUsed: time.Now(), created: time.Now(), } - + cp.clients.Store(key, conn) atomic.AddInt64(&cp.activeCount, 1) - + return newClient, nil } @@ -621,7 +621,7 @@ type pooledConnection struct { func (cp *ConnectionPool) cleanup() { ticker := time.NewTicker(cp.idleTimeout / 2) defer ticker.Stop() - + for range ticker.C { now := time.Now() cp.clients.Range(func(key, val interface{}) bool { @@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager { return &CacheManager{ // Device info rarely changes, cache for 1 hour deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour), - + // Capabilities never change, cache for 24 hours capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour), - + // Volume changes frequently, cache for 5 seconds volumeCache: cache.New(5*time.Second, 10*time.Second), } @@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D if cached, found := cm.deviceInfoCache.Get(deviceID); found { return cached.(*models.DeviceInfo), nil } - + info, err := fetcher() if err != nil { return nil, err } - + cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration) return info, nil } @@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to) }, } - + return &ResilientSoundTouchService{ client: client, cb: gobreaker.NewCircuitBreaker(settings), @@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro result, err := r.cb.Execute(func() (interface{}, error) { return nil, r.client.SetVolume(volume) }) - + if err != nil { r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc() return err } - + return result.(error) } ``` @@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error { // Setup signal handling sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - + // Start services g, ctx := errgroup.WithContext(ctx) - + // HTTP server server := &http.Server{ Addr: app.config.ListenAddr, Handler: app.handler, } - + g.Go(func() error { app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr) if err := server.ListenAndServe(); err != http.ErrServerClosed { @@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error { } return nil }) - + // Health checker g.Go(func() error { return app.healthChecker.Start(ctx) }) - + // WebSocket manager g.Go(func() error { return app.wsManager.Start(ctx) }) - + // Wait for shutdown signal go func() { <-sigChan app.logger.Info("Shutdown signal received") - + // Graceful shutdown with timeout shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + // Shutdown HTTP server if err := server.Shutdown(shutdownCtx); err != nil { app.logger.Error("HTTP server shutdown error", "error", err) } - + // Close WebSocket connections app.wsManager.Shutdown(shutdownCtx) - + // Close connection pool app.connectionPool.Close() }() - + return g.Wait() } ``` @@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error { ```dockerfile # Dockerfile -FROM golang:1.25-alpine AS builder +FROM golang:1.27.0-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ @@ -830,7 +830,7 @@ services: networks: - soundtouch-net restart: unless-stopped - + prometheus: image: prom/prometheus:latest ports: @@ -839,7 +839,7 @@ services: - ./prometheus.yml:/etc/prometheus/prometheus.yml networks: - soundtouch-net - + grafana: image: grafana/grafana:latest ports: @@ -1011,7 +1011,7 @@ groups: annotations: summary: "SoundTouch device {{ $labels.device_name }} is unhealthy" description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes" - + - alert: HighErrorRate expr: rate(soundtouch_errors_total[5m]) > 0.1 for: 5m @@ -1020,7 +1020,7 @@ groups: annotations: summary: "High error rate detected" description: "Error rate is {{ $value }} errors/second over the last 5 minutes" - + - alert: ServiceDown expr: up{job="soundtouch"} == 0 for: 1m @@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error { Timestamp: time.Now(), Devices: make(map[string]DeviceConfig), } - + for deviceID, device := range m.devices { config := DeviceConfig{} - + // Backup presets if presets, err := device.Client.GetPresets(); err == nil { config.Presets = presets } - + // Backup settings if volume, err := device.Client.GetVolume(); err == nil { config.Volume = volume.TargetVolume } - + if bass, err := device.Client.GetBass(); err == nil { config.Bass = bass.TargetBass } - + backup.Devices[deviceID] = config } - + // Save to file data, err := json.MarshalIndent(backup, "", " ") if err != nil { return err } - + filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05")) return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644) } @@ -1083,7 +1083,7 @@ func init() { runtime.GOMAXPROCS(int(limit)) } } - + // Set GC target percentage if os.Getenv("GOGC") == "" { debug.SetGCPerc diff --git a/examples/navigation-station-demo/go.mod b/examples/navigation-station-demo/go.mod index fafd269..9cdbae4 100644 --- a/examples/navigation-station-demo/go.mod +++ b/examples/navigation-station-demo/go.mod @@ -1,8 +1,8 @@ module navigation-station-demo -go 1.26.6 +go 1.27.0 -require github.com/gesellix/bose-soundtouch v0.123.0 +require github.com/gesellix/bose-soundtouch v0.128.0 require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/examples/preset-management/go.mod b/examples/preset-management/go.mod index 38ee4e6..8c4a2d9 100644 --- a/examples/preset-management/go.mod +++ b/examples/preset-management/go.mod @@ -1,8 +1,8 @@ module preset-management-example -go 1.26.6 +go 1.27.0 -require github.com/gesellix/bose-soundtouch v0.123.0 +require github.com/gesellix/bose-soundtouch v0.128.0 require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/go.mod b/go.mod index 4ba3e43..c94f575 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gesellix/bose-soundtouch -go 1.26.6 +go 1.27.0 require ( filippo.io/age v1.3.1