Files
upaas/internal/config/config.go
clawbot 7a34fc999c
All checks were successful
Check / check (push) Successful in 4s
Update golangci-lint to v2.12.2 with canonical config (#187)
Bumps golangci-lint from v2.10.1 to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then fixes every finding the new linter surfaces so `make check` is green.

## Version pins

- `Dockerfile` lint stage: `golangci/golangci-lint:v2.12.2` (Debian-based), tag plus digest pin
- `script/bootstrap`: `GOLANGCI_LINT_VERSION=2.12.2` with updated `linux-amd64`/`linux-arm64` release-archive sha256 pins

## Config

`.golangci.yml` replaced with the canonical config. Material change: the old file declared `version: "2"` but kept settings under the legacy top-level `linters-settings` key, which golangci-lint v2 ignores — so the intended thresholds (`lll` 88, `funlen` 80/50, `cyclop` 15, `dupl` 100) were not being applied. The canonical file moves them under `linters.settings` and drops `issues.exclude-use-default`.

## Lint fixes (216 findings)

- `lll` (96): wrapped lines to the 88-column limit
- `noctx` (46): `httptest.NewRequestWithContext` with `t.Context()` throughout the tests
- `goconst` (24): shared constants for template/JSON keys in `internal/handlers` and repeated test literals
- `gosec` (23): app-page redirects now go through a `redirectToApp` helper that path-escapes the app ID (G710 open redirect); `http.ServeFile` of the internally derived deployment log path annotated like the adjacent `os.Stat` (G703)
- `dupl` (22): extracted a generic `findAllByAppID` in `internal/models`, a `deleteAppResource` helper in `internal/handlers`, a shared `parsePush` in `internal/service/webhook`, and table-driven/helper-based dedup in tests
- `nolintlint` (5): removed `//nolint:funlen` directives made obsolete by the new limits (plus one more that became obsolete after refactoring)
- `nilerr` (3, surfaced during fixing): resource-delete lookups now propagate the find error to the caller

No behavior changes intended; all tests pass and `make check` is green.

Note: golangci-lint v2.12 warns that `gomodguard` is deprecated in favor of `gomodguard_v2` — a future canonical-config update should address this centrally.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #187
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 22:21:42 +02:00

214 lines
5.3 KiB
Go

// Package config provides application configuration via Viper.
package config
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/spf13/viper"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
)
// defaultPort is the default HTTP server port.
const defaultPort = 8080
// sessionSecretFile is the filename for the persisted session secret.
const sessionSecretFile = "session.key"
// sessionSecretBytes is the number of random bytes for session secret.
const sessionSecretBytes = 32
// File permission constants.
const (
dirPermissions = 0o700
filePermissions = 0o600
)
// Params contains dependencies for Config.
type Params struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
// Config holds application configuration.
type Config struct {
Port int
Debug bool
DataDir string
HostDataDir string // Host path for DataDir (Docker bind mounts in container)
DockerHost string
SentryDSN string
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SessionSecret string `json:"-"`
CORSOrigins string
params *Params
log *slog.Logger
}
// New creates a new Config instance from environment and config files.
func New(_ fx.Lifecycle, params Params) (*Config, error) {
log := params.Logger.Get()
name := params.Globals.Appname
if name == "" {
name = "upaas"
}
setupViper(name)
cfg, err := buildConfig(log, &params)
if err != nil {
return nil, err
}
configureDebugLogging(cfg, params)
return cfg, nil
}
func setupViper(name string) {
// Config file settings
viper.SetConfigName(name)
viper.SetConfigType("yaml")
viper.AddConfigPath("/etc/" + name)
viper.AddConfigPath("$HOME/.config/" + name)
viper.AddConfigPath(".")
// Environment variables override everything
viper.SetEnvPrefix("UPAAS")
viper.AutomaticEnv()
// Defaults
// PORT is not prefixed with UPAAS_ for compatibility
_ = viper.BindEnv("PORT", "PORT")
viper.SetDefault("PORT", defaultPort)
viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "./data")
viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("MAINTENANCE_MODE", false)
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("SESSION_SECRET", "")
viper.SetDefault("CORS_ORIGINS", "")
}
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
// Read config file (optional)
err := viper.ReadInConfig()
if err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if !errors.As(err, &configFileNotFoundError) {
log.Error("config file malformed", "error", err)
return nil, fmt.Errorf("config file malformed: %w", err)
}
// Config file not found is OK
}
dataDir := viper.GetString("DATA_DIR")
hostDataDir := viper.GetString("HOST_DATA_DIR")
if hostDataDir == "" {
hostDataDir = dataDir
}
// Build config struct
cfg := &Config{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: dataDir,
HostDataDir: hostDataDir,
DockerHost: viper.GetString("DOCKER_HOST"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SessionSecret: viper.GetString("SESSION_SECRET"),
CORSOrigins: viper.GetString("CORS_ORIGINS"),
params: params,
log: log,
}
// Load or generate session secret
if cfg.SessionSecret == "" {
secret, err := loadOrCreateSessionSecret(log, cfg.DataDir)
if err != nil {
return nil, fmt.Errorf("failed to initialize session secret: %w", err)
}
cfg.SessionSecret = secret
}
return cfg, nil
}
func loadOrCreateSessionSecret(log *slog.Logger, dataDir string) (string, error) {
secretPath := filepath.Join(dataDir, sessionSecretFile)
// Try to read existing secret
//nolint:gosec // secretPath is constructed from trusted config, not user input
data, err := os.ReadFile(secretPath)
if err == nil {
log.Info("loaded session secret from file", "path", secretPath)
return string(data), nil
}
if !os.IsNotExist(err) {
return "", fmt.Errorf("failed to read session secret file: %w", err)
}
// Generate new secret
secretBytes := make([]byte, sessionSecretBytes)
_, err = rand.Read(secretBytes)
if err != nil {
return "", fmt.Errorf("failed to generate random secret: %w", err)
}
secret := hex.EncodeToString(secretBytes)
// Ensure data directory exists
err = os.MkdirAll(dataDir, dirPermissions)
if err != nil {
return "", fmt.Errorf("failed to create data directory: %w", err)
}
// Write secret to file
err = os.WriteFile(secretPath, []byte(secret), filePermissions)
if err != nil {
return "", fmt.Errorf("failed to write session secret file: %w", err)
}
log.Info("generated new session secret", "path", secretPath)
return secret, nil
}
func configureDebugLogging(cfg *Config, params Params) {
// Enable debug logging if configured
if cfg.Debug {
params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get()
}
}
// DatabasePath returns the full path to the SQLite database file.
func (c *Config) DatabasePath() string {
return c.DataDir + "/upaas.db"
}