Initila commit
Build Docker image on push / docker (push) Successful in 22s

This commit is contained in:
2026-08-18 00:46:59 +02:00
commit 9db7aa2560
28 changed files with 2823 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
// Package application wires configuration, product collectors and the HTTP server together.
package application
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus"
"lostak.dev/shelly-exporter/collector"
"lostak.dev/shelly-exporter/configuration"
"lostak.dev/shelly-exporter/shelly"
)
// Application is a configured Shelly exporter HTTP server.
type Application struct {
config *configuration.Configuration
registry *prometheus.Registry
server *http.Server
}
// New loads a configuration and registers one collector per Shelly device.
func New(configPath string) (*Application, error) {
config, err := configuration.Load(configPath)
if err != nil {
return nil, err
}
log.SetLevel(log.AllLevels[config.LogLevel])
registry := prometheus.NewRegistry()
deviceCollectors := make(collector.Group, 0, len(config.Shelly.Devices))
for _, device := range config.Shelly.Devices {
client, err := shelly.NewClient(
device.URL,
device.Username,
device.Password,
time.Duration(config.Shelly.TimeoutSeconds)*time.Second,
)
if err != nil {
return nil, fmt.Errorf("create client for device %q: %w", device.Name, err)
}
deviceCollector, err := collector.NewDeviceCollector(device, client)
if err != nil {
return nil, fmt.Errorf("configure device %q: %w", device.Name, err)
}
deviceCollectors = append(deviceCollectors, deviceCollector)
log.Infof("Registered %s metrics for Shelly device %q.", device.Product, device.Name)
}
if err := registry.Register(deviceCollectors); err != nil {
return nil, fmt.Errorf("register device metrics: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{EnableOpenMetrics: true}))
mux.HandleFunc("/-/healthy", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte("OK\n"))
})
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
return &Application{
config: config,
registry: registry,
server: &http.Server{
Addr: address,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
},
}, nil
}
// Run serves metrics until the context is cancelled or the server fails.
func (app *Application) Run(ctx context.Context) error {
listener, err := net.Listen("tcp", app.server.Addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", app.server.Addr, err)
}
serverError := make(chan error, 1)
log.Infof("Shelly exporter started on %s.", listener.Addr())
go func() {
serverError <- app.server.Serve(listener)
}()
select {
case err := <-serverError:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := app.server.Shutdown(shutdownContext); err != nil {
return fmt.Errorf("shut down HTTP server: %w", err)
}
err = <-serverError
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
}
+40
View File
@@ -0,0 +1,40 @@
package application
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestApplicationSupportsMultipleDevicesAndGracefulShutdown(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
config := []byte(`host: 127.0.0.1
port: 9090
logLevel: 4
shelly:
timeoutSeconds: 1
devices:
- name: office
product: plug_s
url: http://192.0.2.1
- name: kitchen
product: plug_s
url: http://192.0.2.2
`)
if err := os.WriteFile(configPath, config, 0o600); err != nil {
t.Fatal(err)
}
app, err := New(configPath)
if err != nil {
t.Fatalf("New() returned an unexpected error: %v", err)
}
app.server.Addr = "127.0.0.1:0"
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := app.Run(ctx); err != nil {
t.Fatalf("Run() returned an unexpected error: %v", err)
}
}