diff --git a/cmd/sentinel/BUILD.bazel b/cmd/sentinel/BUILD.bazel index a447fbd543..af919e1181 100644 --- a/cmd/sentinel/BUILD.bazel +++ b/cmd/sentinel/BUILD.bazel @@ -7,7 +7,7 @@ go_library( visibility = ["//visibility:public"], deps = [ "//pkg/cli", - "//pkg/uid", + "//pkg/config", "//svc/sentinel", ], ) diff --git a/cmd/sentinel/main.go b/cmd/sentinel/main.go index db7341b6a2..f249b6169f 100644 --- a/cmd/sentinel/main.go +++ b/cmd/sentinel/main.go @@ -2,10 +2,9 @@ package sentinel import ( "context" - "time" "github.com/unkeyed/unkey/pkg/cli" - "github.com/unkeyed/unkey/pkg/uid" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/svc/sentinel" ) @@ -19,72 +18,17 @@ var Cmd = &cli.Command{ Name: "sentinel", Usage: "Run the Unkey Sentinel server (deployment proxy)", Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the Sentinel server to listen on. Default: 8080", - cli.Default(8080), cli.EnvVar("UNKEY_HTTP_PORT")), - - // Instance Identification - cli.String("sentinel-id", "Unique identifier for this sentinel instance. Auto-generated if not provided.", - cli.Default(uid.New("sentinel", 4)), cli.EnvVar("UNKEY_SENTINEL_ID")), - - cli.String("workspace-id", "Workspace ID this sentinel serves. Required.", - cli.Required(), cli.EnvVar("UNKEY_WORKSPACE_ID")), - - cli.String("environment-id", "Environment ID this sentinel serves (handles all deployments in this environment). Required.", - cli.Required(), cli.EnvVar("UNKEY_ENVIRONMENT_ID")), - - cli.String("region", "Geographic region identifier. Used for logging. Default: unknown", - cli.Default("unknown"), cli.EnvVar("UNKEY_REGION")), - - // Database Configuration - cli.String("database-primary", "MySQL connection string for primary database. Required.", - cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), - - cli.String("database-replica", "MySQL connection string for read-replica.", - cli.EnvVar("UNKEY_DATABASE_REPLICA")), - - cli.String("clickhouse-url", "ClickHouse connection string. Optional.", - cli.EnvVar("UNKEY_CLICKHOUSE_URL")), - - // Observability - cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", - cli.EnvVar("UNKEY_OTEL")), - cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Default: 0.25", - cli.Default(0.25), cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), - cli.Int("prometheus-port", "Enable Prometheus /metrics endpoint on specified port. Set to 0 to disable.", cli.EnvVar("UNKEY_PROMETHEUS_PORT")), - - // Logging Sampler Configuration - cli.Float("log-sample-rate", "Baseline probability (0.0-1.0) of emitting log events. Default: 1.0", - cli.Default(1.0), cli.EnvVar("UNKEY_LOG_SAMPLE_RATE")), - cli.Duration("log-slow-threshold", "Duration threshold for slow event sampling. Default: 1s", - cli.Default(time.Second), cli.EnvVar("UNKEY_LOG_SLOW_THRESHOLD")), + cli.String("config", "Path to a TOML config file", + cli.Default("unkey.toml"), cli.EnvVar("UNKEY_CONFIG")), }, Action: action, } func action(ctx context.Context, cmd *cli.Command) error { - return sentinel.Run(ctx, sentinel.Config{ - // Instance identification - SentinelID: cmd.String("sentinel-id"), - WorkspaceID: cmd.String("workspace-id"), - EnvironmentID: cmd.String("environment-id"), - Region: cmd.String("region"), - - // HTTP configuration - HttpPort: cmd.Int("http-port"), - - // Database configuration - DatabasePrimary: cmd.String("database-primary"), - DatabaseReadonlyReplica: cmd.String("database-replica"), - ClickhouseURL: cmd.String("clickhouse-url"), - - // Observability - OtelEnabled: cmd.Bool("otel"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - PrometheusPort: cmd.Int("prometheus-port"), + cfg, err := config.Load[sentinel.Config](cmd.String("config")) + if err != nil { + return cli.Exit("Failed to load config: "+err.Error(), 1) + } - // Logging sampler configuration - LogSampleRate: cmd.Float("log-sample-rate"), - LogSlowThreshold: cmd.Duration("log-slow-threshold"), - }) + return sentinel.Run(ctx, cfg) } diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 92ab4b1836..43f4db3d9c 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -63,7 +63,7 @@ services: deploy: replicas: 3 endpoint_mode: vip - command: ["run", "api"] + command: ["run", "api", "--config", "/etc/unkey/api.toml"] build: context: ../ dockerfile: ./Dockerfile @@ -80,22 +80,8 @@ services: condition: service_started ctrl-api: condition: service_started - environment: - UNKEY_HTTP_PORT: 7070 - UNKEY_REDIS_URL: "redis://redis:6379" - UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true" - UNKEY_CLICKHOUSE_URL: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" - UNKEY_CHPROXY_AUTH_TOKEN: "chproxy-test-token-123" - UNKEY_OTEL: false - UNKEY_VAULT_URL: "http://vault:8060" - UNKEY_VAULT_TOKEN: "vault-test-token-123" - UNKEY_KAFKA_BROKERS: "kafka:9092" - UNKEY_CLICKHOUSE_ANALYTICS_URL: "http://clickhouse:8123/default" - UNKEY_CTRL_URL: "http://ctrl-api:7091" - UNKEY_CTRL_TOKEN: "your-local-dev-key" - UNKEY_PPROF_ENABLED: "true" - UNKEY_PPROF_USERNAME: "admin" - UNKEY_PPROF_PASSWORD: "password" + volumes: + - ./config/api.toml:/etc/unkey/api.toml:ro redis: networks: @@ -126,20 +112,14 @@ services: build: context: ../ dockerfile: Dockerfile - command: ["run", "vault"] + command: ["run", "vault", "--config", "/etc/unkey/vault.toml"] ports: - "8060:8060" depends_on: s3: condition: service_healthy - environment: - UNKEY_HTTP_PORT: "8060" - UNKEY_S3_URL: "http://s3:3902" - UNKEY_S3_BUCKET: "vault" - UNKEY_S3_ACCESS_KEY_ID: "minio_root_user" - UNKEY_S3_ACCESS_KEY_SECRET: "minio_root_password" - UNKEY_MASTER_KEYS: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" - UNKEY_BEARER_TOKEN: "vault-test-token-123" + volumes: + - ./config/vault.toml:/etc/unkey/vault.toml:ro healthcheck: test: ["CMD", "/unkey", "healthcheck", "http://localhost:8060/health/live"] timeout: 10s @@ -210,29 +190,17 @@ services: args: VERSION: "latest" container_name: krane - command: ["run", "krane"] + command: ["run", "krane", "--config", "/etc/unkey/krane.toml"] ports: - "8070:8070" volumes: # Mount Docker socket for Docker backend support - /var/run/docker.sock:/var/run/docker.sock + - ./config/krane.toml:/etc/unkey/krane.toml:ro depends_on: vault: condition: service_healthy environment: - # Server configuration - UNKEY_REGION: "local.dev" # currently required to receive filtered events from ctrl - UNKEY_CONTROL_PLANE_URL: "http://ctrl-api:7091" - UNKEY_CONTROL_PLANE_BEARER: "your-local-dev-key" - - # Backend configuration - use Docker backend for development - UNKEY_KRANE_BACKEND: "docker" - UNKEY_DOCKER_SOCKET: "/var/run/docker.sock" - - # Vault configuration for secrets decryption - UNKEY_VAULT_URL: "http://vault:8060" - UNKEY_VAULT_TOKEN: "vault-test-token-123" - UNKEY_REGISTRY_URL: "${UNKEY_REGISTRY_URL:-}" UNKEY_REGISTRY_USERNAME: "${UNKEY_REGISTRY_USERNAME:-}" UNKEY_REGISTRY_PASSWORD: "${UNKEY_REGISTRY_PASSWORD:-}" @@ -266,7 +234,7 @@ services: args: VERSION: "latest" container_name: ctrl-api - command: ["run", "ctrl", "api"] + command: ["run", "ctrl", "api", "--config", "/etc/unkey/ctrl-api.toml"] ports: - "7091:7091" depends_on: @@ -282,30 +250,10 @@ services: clickhouse: condition: service_healthy required: true + volumes: + - ./config/ctrl-api.toml:/etc/unkey/ctrl-api.toml:ro environment: - UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" - - # Control plane configuration - UNKEY_HTTP_PORT: "7091" - - # Restate configuration (ctrl api only needs ingress client, not server) - UNKEY_RESTATE_INGRESS_URL: "http://restate:8080" - UNKEY_RESTATE_ADMIN_URL: "http://restate:9070" - UNKEY_RESTATE_API_KEY: "" - - # Build configuration (for presigned URLs) - UNKEY_BUILD_S3_URL: "${UNKEY_BUILD_S3_URL:-http://s3:3902}" - UNKEY_BUILD_S3_EXTERNAL_URL: "${UNKEY_BUILD_S3_EXTERNAL_URL:-http://localhost:3902}" - UNKEY_BUILD_S3_BUCKET: "build-contexts" - UNKEY_BUILD_S3_ACCESS_KEY_ID: "${UNKEY_BUILD_S3_ACCESS_KEY_ID:-minio_root_user}" - UNKEY_BUILD_S3_ACCESS_KEY_SECRET: "${UNKEY_BUILD_S3_ACCESS_KEY_SECRET:-minio_root_password}" - - # API key for simple authentication - UNKEY_AUTH_TOKEN: "your-local-dev-key" - - # Certificate bootstrap - UNKEY_DEFAULT_DOMAIN: "unkey.local" - UNKEY_CNAME_DOMAIN: "unkey.local" + UNKEY_GITHUB_APP_WEBHOOK_SECRET: "${UNKEY_GITHUB_APP_WEBHOOK_SECRET:-}" ctrl-worker: networks: @@ -316,7 +264,7 @@ services: args: VERSION: "latest" container_name: ctrl-worker - command: ["run", "ctrl", "worker"] + command: ["run", "ctrl", "worker", "--config", "/etc/unkey/ctrl-worker.toml"] env_file: - .env.depot ports: @@ -345,42 +293,7 @@ services: required: true volumes: - /var/run/docker.sock:/var/run/docker.sock - environment: - UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" - - # Domain configuration (used by deploy and routing services) - UNKEY_DEFAULT_DOMAIN: "unkey.local" - UNKEY_CNAME_DOMAIN: "unkey.local" - - # Restate configuration - UNKEY_RESTATE_ADMIN_URL: "http://restate:9070" - UNKEY_RESTATE_HTTP_PORT: "9080" - UNKEY_RESTATE_REGISTER_AS: "http://ctrl-worker:9080" - - # Vault service for secret encryption - UNKEY_VAULT_URL: "http://vault:8060" - UNKEY_VAULT_TOKEN: "vault-test-token-123" - - # Build configuration (loaded from .env.depot) - UNKEY_BUILD_S3_BUCKET: "build-contexts" - - # Build configuration - UNKEY_BUILD_PLATFORM: "linux/amd64" - - # Registry configuration (UNKEY_REGISTRY_PASSWORD loaded from .env.depot) - UNKEY_REGISTRY_URL: "registry.depot.dev" - UNKEY_REGISTRY_USERNAME: "x-token" - - # Depot-specific configuration - UNKEY_DEPOT_API_URL: "https://api.depot.dev" - UNKEY_DEPOT_PROJECT_REGION: "us-east-1" - - # ClickHouse - UNKEY_CLICKHOUSE_URL: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" - UNKEY_CLICKHOUSE_ADMIN_URL: "clickhouse://unkey_user_admin:C57RqT5EPZBqCJkMxN9mEZZEzMPcw9yBlwhIizk99t7kx6uLi9rYmtWObsXzdl@clickhouse:9000?secure=false&skip_verify=true" - - # Sentinel image for deployments - UNKEY_SENTINEL_IMAGE: "unkey/sentinel:latest" + - ./config/ctrl-worker.toml:/etc/unkey/ctrl-worker.toml:ro otel: networks: diff --git a/pkg/db/deployment_topology_update_desired_status.sql_generated.go b/pkg/db/deployment_topology_update_desired_status.sql_generated.go index f172fd2363..136f832cc1 100644 --- a/pkg/db/deployment_topology_update_desired_status.sql_generated.go +++ b/pkg/db/deployment_topology_update_desired_status.sql_generated.go @@ -31,6 +31,12 @@ type UpdateDeploymentTopologyDesiredStatusParams struct { // SET desired_status = ?, version = ?, updated_at = ? // WHERE deployment_id = ? AND region = ? func (q *Queries) UpdateDeploymentTopologyDesiredStatus(ctx context.Context, db DBTX, arg UpdateDeploymentTopologyDesiredStatusParams) error { - _, err := db.ExecContext(ctx, updateDeploymentTopologyDesiredStatus, arg.DesiredStatus, arg.Version, arg.UpdatedAt, arg.DeploymentID, arg.Region) + _, err := db.ExecContext(ctx, updateDeploymentTopologyDesiredStatus, + arg.DesiredStatus, + arg.Version, + arg.UpdatedAt, + arg.DeploymentID, + arg.Region, + ) return err } diff --git a/pkg/db/querier_generated.go b/pkg/db/querier_generated.go index 310908c977..0ec1ce628b 100644 --- a/pkg/db/querier_generated.go +++ b/pkg/db/querier_generated.go @@ -2412,13 +2412,6 @@ type Querier interface { // SET desired_state = ?, updated_at = ? // WHERE id = ? UpdateDeploymentDesiredState(ctx context.Context, db DBTX, arg UpdateDeploymentDesiredStateParams) error - //UpdateDeploymentTopologyDesiredStatus updates the desired_status and version of a topology entry. - // A new version is required so that WatchDeployments picks up the change. - // - // UPDATE `deployment_topology` - // SET desired_status = ?, version = ?, updated_at = ? - // WHERE deployment_id = ? AND region = ? - UpdateDeploymentTopologyDesiredStatus(ctx context.Context, db DBTX, arg UpdateDeploymentTopologyDesiredStatusParams) error //UpdateDeploymentImage // // UPDATE deployments @@ -2437,6 +2430,13 @@ type Querier interface { // SET status = ?, updated_at = ? // WHERE id = ? UpdateDeploymentStatus(ctx context.Context, db DBTX, arg UpdateDeploymentStatusParams) error + // UpdateDeploymentTopologyDesiredStatus updates the desired_status and version of a topology entry. + // A new version is required so that WatchDeployments picks up the change. + // + // UPDATE `deployment_topology` + // SET desired_status = ?, version = ?, updated_at = ? + // WHERE deployment_id = ? AND region = ? + UpdateDeploymentTopologyDesiredStatus(ctx context.Context, db DBTX, arg UpdateDeploymentTopologyDesiredStatusParams) error //UpdateFrontlineRouteDeploymentId // // UPDATE frontline_routes diff --git a/svc/sentinel/BUILD.bazel b/svc/sentinel/BUILD.bazel index 2f9243327f..cef40f0f30 100644 --- a/svc/sentinel/BUILD.bazel +++ b/svc/sentinel/BUILD.bazel @@ -9,9 +9,9 @@ go_library( importpath = "github.com/unkeyed/unkey/svc/sentinel", visibility = ["//visibility:public"], deps = [ - "//pkg/assert", "//pkg/clickhouse", "//pkg/clock", + "//pkg/config", "//pkg/db", "//pkg/logger", "//pkg/otel", diff --git a/svc/sentinel/config.go b/svc/sentinel/config.go index 992ac4e0e9..84d11b174b 100644 --- a/svc/sentinel/config.go +++ b/svc/sentinel/config.go @@ -3,53 +3,68 @@ package sentinel import ( "fmt" "slices" - "time" - "github.com/unkeyed/unkey/pkg/assert" + "github.com/unkeyed/unkey/pkg/config" ) +// ClickHouseConfig configures connections to ClickHouse for analytics storage. +// When URL is empty, a no-op analytics backend is used. +type ClickHouseConfig struct { + // URL is the ClickHouse connection string. + // Example: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" + URL string `toml:"url"` +} + +// Config holds the complete configuration for the Sentinel server. It is +// designed to be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[sentinel.Config]("/etc/unkey/sentinel.toml") +// +// Environment variables are expanded in file values using ${VAR} or +// ${VAR:-default} syntax before parsing. Struct tag defaults are applied to +// any field left at its zero value after parsing, and validation runs +// automatically via [Config.Validate]. type Config struct { - SentinelID string + // SentinelID identifies this particular sentinel instance. Used in log + // attribution and request tracing. + SentinelID string `toml:"sentinel_id"` - WorkspaceID string + // WorkspaceID is the workspace this sentinel serves. + WorkspaceID string `toml:"workspace_id" config:"required,nonempty"` - // EnvironmentID identifies which environment this sentinel serves + // EnvironmentID identifies which environment this sentinel serves. // A single environment may have multiple deployments, and this sentinel - // handles all of them based on the deployment ID passed in each request - EnvironmentID string + // handles all of them based on the deployment ID passed in each request. + EnvironmentID string `toml:"environment_id" config:"required,nonempty"` - Region string + // Region is the geographic region identifier (e.g. "us-east-1.aws"). + // Included in structured logs and used for routing decisions. + Region string `toml:"region" config:"required,oneof=local.dev|us-east-1.aws|us-east-2.aws|us-west-1.aws|us-west-2.aws|eu-central-1.aws"` - HttpPort int + // HttpPort is the TCP port the sentinel server binds to. + HttpPort int `toml:"http_port" config:"default=8080,min=1,max=65535"` - DatabasePrimary string - DatabaseReadonlyReplica string + // PrometheusPort starts a Prometheus /metrics HTTP endpoint on the + // specified port. Set to 0 (the default) to disable the endpoint entirely. + PrometheusPort int `toml:"prometheus_port"` - ClickhouseURL string + // Database configures MySQL connections. See [config.DatabaseConfig]. + Database config.DatabaseConfig `toml:"database"` - OtelEnabled bool - OtelTraceSamplingRate float64 - PrometheusPort int + // ClickHouse configures analytics storage. See [ClickHouseConfig]. + ClickHouse ClickHouseConfig `toml:"clickhouse"` - // --- Logging sampler configuration --- + // Otel configures OpenTelemetry export. See [config.OtelConfig]. + Otel config.OtelConfig `toml:"otel"` - // LogSampleRate is the baseline probability (0.0-1.0) of emitting log events. - LogSampleRate float64 - - // LogSlowThreshold defines what duration qualifies as "slow" for sampling. - LogSlowThreshold time.Duration + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` } -func (c Config) Validate() error { - err := assert.All( - assert.NotEmpty(c.WorkspaceID, "workspace ID is required"), - assert.NotEmpty(c.EnvironmentID, "environment ID is required"), - ) - - if err != nil { - return err - } - +// Validate checks cross-field constraints that cannot be expressed through +// struct tags alone. It implements [config.Validator] so that [config.Load] +// calls it automatically after tag-level validation. +func (c *Config) Validate() error { validRegions := []string{ "local.dev", "us-east-1.aws", @@ -61,7 +76,6 @@ func (c Config) Validate() error { if !slices.Contains(validRegions, c.Region) { return fmt.Errorf("invalid region: %s, must be one of %v", c.Region, validRegions) - } return nil diff --git a/svc/sentinel/run.go b/svc/sentinel/run.go index 4d38a7b7d1..8de088059c 100644 --- a/svc/sentinel/run.go +++ b/svc/sentinel/run.go @@ -30,21 +30,21 @@ func Run(ctx context.Context, cfg Config) error { } logger.SetSampler(logger.TailSampler{ - SlowThreshold: cfg.LogSlowThreshold, - SampleRate: cfg.LogSampleRate, + SlowThreshold: cfg.Logging.SlowThreshold, + SampleRate: cfg.Logging.SampleRate, }) clk := clock.New() // Initialize OTEL before creating logger so the logger picks up the OTLP handler var shutdownGrafana func(context.Context) error - if cfg.OtelEnabled { + if cfg.Otel.Enabled { shutdownGrafana, err = otel.InitGrafana(ctx, otel.Config{ Application: "sentinel", Version: version.Version, InstanceID: cfg.SentinelID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -87,8 +87,8 @@ func Run(ctx context.Context, cfg Config) error { } database, err := db.New(db.Config{ - PrimaryDSN: cfg.DatabasePrimary, - ReadOnlyDSN: cfg.DatabaseReadonlyReplica, + PrimaryDSN: cfg.Database.Primary, + ReadOnlyDSN: cfg.Database.ReadonlyReplica, }) if err != nil { return fmt.Errorf("unable to create db: %w", err) @@ -96,9 +96,9 @@ func Run(ctx context.Context, cfg Config) error { r.Defer(database.Close) var ch clickhouse.ClickHouse = clickhouse.NewNoop() - if cfg.ClickhouseURL != "" { + if cfg.ClickHouse.URL != "" { ch, err = clickhouse.New(clickhouse.Config{ - URL: cfg.ClickhouseURL, + URL: cfg.ClickHouse.URL, }) if err != nil { return fmt.Errorf("unable to create clickhouse: %w", err)