Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ concurrency:
# Define versions once here. When upgrading Go or Node, change
# the value here and it applies to every job automatically.
env:
GO_VERSION: '1.24'
GO_VERSION: '1.25'
NODE_VERSION: '20'
GOLANGCI_LINT_VERSION: 'v2.1.6'
GOLANGCI_LINT_VERSION: 'v2.4.0'
MIGRATE_VERSION: 'v4.17.0'
COVERAGE_THRESHOLD: '0'

Expand Down Expand Up @@ -139,7 +139,7 @@ jobs:
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
JWT_SECRET: ci-test-jwt-secret-not-for-production
ENVIRONMENT: test
BREACH_DETECTION_INTERVAL: '@every 1h'
BREACH_DETECTION_INTERVAL: '1h'

# Read the total coverage percentage and fail the build if
# it is below the threshold defined in the env block above.
Expand Down
32 changes: 30 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
package main

import "fmt"
import (
"fmt"
"log/slog"
"os"

"github.com/ratifydata/ratify/internal/config"
)

func main() {
fmt.Println("Ratify server starting...")
// Load configuration from environment variables and .env file.
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load configuration", "error", err)
os.Exit(1)
}

// Structured JSON logging — standard for production services.
// In development, this is readable. In production, log
// aggregation tools (Datadog, Loki) can parse it.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

slog.Info("Ratify server starting",
"port", cfg.Port,
"environment", cfg.Environment,
"breach_interval", cfg.BreachDetectionInterval,
)

// Placeholder — the real server implementation would go here.
fmt.Printf("Ratify server ready on port %d\n", cfg.Port)
}
19 changes: 18 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
module github.com/ratifydata/ratify

go 1.24
go 1.25

require github.com/spf13/viper v1.21.0

require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
47 changes: 47 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Empty file removed internal/config/.gitkeep
Empty file.
155 changes: 155 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package config

import (
"fmt"
"strings"
"time"

"github.com/spf13/viper"
)

// Config holds all configuration for the Ratify server.
// Values are loaded from environment variables or a .env file.
// No configuration value is hardcoded anywhere in the application.
type Config struct {
// Server
Port int `mapstructure:"PORT"`
Environment string `mapstructure:"ENVIRONMENT"`

// Database
// This is Ratify's own metadata database — not the databases
// it monitors. It stores contracts, proposals, audit logs,
// and encrypted credentials.
DatabaseURL string `mapstructure:"DATABASE_URL"`

// Security
// EncryptionKey is used to encrypt database credentials at
// rest using AES-256-GCM. Must be exactly 32 bytes (64 hex chars).
EncryptionKey string `mapstructure:"ENCRYPTION_KEY"`

// JWTSecret is used to sign and verify session tokens for
// the web UI. Must be kept secret.
JWTSecret string `mapstructure:"JWT_SECRET"`

// Email (SMTP)
SMTPHost string `mapstructure:"SMTP_HOST"`
SMTPPort int `mapstructure:"SMTP_PORT"`
SMTPUsername string `mapstructure:"SMTP_USERNAME"`
SMTPPassword string `mapstructure:"SMTP_PASSWORD"`
SMTPFrom string `mapstructure:"SMTP_FROM"`

// Breach detection
// How often Ratify compares live schemas against active contracts.
// Parsed as a Go duration string: "1h", "30m", "24h".
BreachDetectionInterval string `mapstructure:"BREACH_DETECTION_INTERVAL"`
}

// Load reads configuration from environment variables and the
// .env file (if present). Environment variables take priority
// over .env file values.
//
// Returns an error if any required variable is missing or invalid.
func Load() (*Config, error) {
v := viper.New()

// Tell Viper to look for a .env file in the current directory.
// If no .env file exists, Viper falls back to environment
// variables and defaults — it does not error.
v.SetConfigFile(".env")
v.SetConfigType("env")

// Read the .env file. Ignore the error if the file does not
// exist — this is expected in Docker and production environments
// where variables are injected directly.
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// The file exists but could not be read — that is a
// real error worth reporting.
_ = ok // file simply does not exist, continue
}
}

// Tell Viper to also read from real environment variables.
// Environment variables override .env file values.
v.AutomaticEnv()

// Set defaults for every variable.
// These are used when no value is provided in .env or the
// environment. Safe values that work for local development.
v.SetDefault("PORT", 8080)
v.SetDefault("ENVIRONMENT", "development")
v.SetDefault("DATABASE_URL", "postgresql://ratify:ratify@localhost:5432/ratify?sslmode=disable")
v.SetDefault("ENCRYPTION_KEY", strings.Repeat("0", 64))
v.SetDefault("JWT_SECRET", "local-dev-jwt-secret-change-in-production")
v.SetDefault("SMTP_HOST", "smtp.mailtrap.io")
v.SetDefault("SMTP_PORT", 587)
v.SetDefault("SMTP_USERNAME", "")
v.SetDefault("SMTP_PASSWORD", "")
v.SetDefault("SMTP_FROM", "ratify@example.com")
v.SetDefault("BREACH_DETECTION_INTERVAL", "1h")

// Unmarshal all values into the Config struct.
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}

// Validate required fields that have no safe default.
if err := validate(&cfg); err != nil {
return nil, err
}

return &cfg, nil
}

// IsProduction returns true when the application is running in
// production. Used to enable stricter security settings.
func (c *Config) IsProduction() bool {
return c.Environment == "production"
}

// IsDevelopment returns true when the application is running
// in development mode.
func (c *Config) IsDevelopment() bool {
return c.Environment == "development"
}

// BreachInterval parses the BreachDetectionInterval string into
// a time.Duration. Returns the parsed duration or an error if
// the string is not a valid Go duration.
func (c *Config) BreachInterval() (time.Duration, error) {
d, err := time.ParseDuration(c.BreachDetectionInterval)
if err != nil {
return 0, fmt.Errorf(
"invalid BREACH_DETECTION_INTERVAL %q: must be a valid Go duration (e.g. 1h, 30m): %w",
c.BreachDetectionInterval,
err,
)
}
return d, nil
}

// validate checks that required configuration values are present
// and correctly formatted. Returns a descriptive error for the
// first problem found.
func validate(cfg *Config) error {
if cfg.DatabaseURL == "" {
return fmt.Errorf("DATABASE_URL is required")
}
if len(cfg.EncryptionKey) != 64 {
return fmt.Errorf(
"ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got %d characters",
len(cfg.EncryptionKey),
)
}
if cfg.JWTSecret == "" {
return fmt.Errorf("JWT_SECRET is required")
}
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf(
"PORT must be between 1 and 65535, got %d",
cfg.Port,
)
}
return nil
}
Loading
Loading