diff --git a/cmd/api/BUILD.bazel b/cmd/api/BUILD.bazel index ea2b3ced4d..bafa21853e 100644 --- a/cmd/api/BUILD.bazel +++ b/cmd/api/BUILD.bazel @@ -8,8 +8,8 @@ go_library( deps = [ "//pkg/cli", "//pkg/clock", + "//pkg/config", "//pkg/tls", - "//pkg/uid", "//svc/api", ], ) diff --git a/cmd/api/main.go b/cmd/api/main.go index 1c03d22dda..fe98be010e 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,12 +2,11 @@ package api import ( "context" - "time" "github.com/unkeyed/unkey/pkg/cli" "github.com/unkeyed/unkey/pkg/clock" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/tls" - "github.com/unkeyed/unkey/pkg/uid" "github.com/unkeyed/unkey/svc/api" ) @@ -22,187 +21,29 @@ var Cmd = &cli.Command{ Usage: "Run the Unkey API server for validating and managing API keys", Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the API server to listen on. Default: 7070", - cli.Default(7070), cli.EnvVar("UNKEY_HTTP_PORT")), - cli.Bool("color", "Enable colored log output. Default: true", - cli.Default(true), cli.EnvVar("UNKEY_LOGS_COLOR")), - cli.Bool("test-mode", "Enable test mode. WARNING: Potentially unsafe, may trust client inputs blindly. Default: false", - cli.Default(false), cli.EnvVar("UNKEY_TEST_MODE")), - - // Instance Identification - cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics.", - cli.EnvVar("UNKEY_PLATFORM")), - cli.String("image", "Container image identifier. Used for logging and metrics.", - cli.EnvVar("UNKEY_IMAGE")), - cli.String("region", "Geographic region identifier. Used for logging and routing. Default: unknown", - cli.Default("unknown"), cli.EnvVar("UNKEY_REGION"), cli.EnvVar("AWS_REGION")), - cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided.", - cli.Default(uid.New(uid.InstancePrefix, 4)), cli.EnvVar("UNKEY_INSTANCE_ID")), - - // Database Configuration - cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", - cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), - cli.String("database-replica", "MySQL connection string for read-replica. Reduces load on primary database. Format same as database-primary.", - cli.EnvVar("UNKEY_DATABASE_REPLICA")), - - // Caching and Storage - cli.String("redis-url", "Redis connection string for rate-limiting and distributed counters. Example: redis://localhost:6379", - cli.EnvVar("UNKEY_REDIS_URL")), - cli.String("clickhouse-url", "ClickHouse connection string for analytics. Recommended for production. Example: clickhouse://user:pass@host:9000/unkey", - cli.EnvVar("UNKEY_CLICKHOUSE_URL")), - cli.String("clickhouse-analytics-url", "ClickHouse base URL for workspace-specific analytics connections. Workspace credentials are injected programmatically. Example: http://clickhouse:8123/default", - cli.EnvVar("UNKEY_CLICKHOUSE_ANALYTICS_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). Only used when --otel is provided. 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")), - - // TLS Configuration - cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS.", - cli.EnvVar("UNKEY_TLS_CERT_FILE")), - cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS.", - cli.EnvVar("UNKEY_TLS_KEY_FILE")), - - // Vault Configuration - cli.String("vault-url", "URL of the remote vault service for encryption/decryption", - cli.EnvVar("UNKEY_VAULT_URL")), - cli.String("vault-token", "Bearer token for vault service authentication", - cli.EnvVar("UNKEY_VAULT_TOKEN")), - - // Kafka Configuration - cli.StringSlice("kafka-brokers", "Comma-separated list of Kafka broker addresses for distributed cache invalidation", - cli.EnvVar("UNKEY_KAFKA_BROKERS")), - - // ClickHouse Proxy Service Configuration - cli.String( - "chproxy-auth-token", - "Authentication token for ClickHouse proxy endpoints. Required when proxy is enabled.", - cli.EnvVar("UNKEY_CHPROXY_AUTH_TOKEN"), - ), - - // Profiling Configuration - cli.Bool( - "pprof-enabled", - "Enable pprof profiling endpoints at /debug/pprof/*. Default: false", - cli.Default(false), - cli.EnvVar("UNKEY_PPROF_ENABLED"), - ), - cli.String( - "pprof-username", - "Username for pprof Basic Auth. Optional - if username and password are not set, pprof will be accessible without authentication.", - cli.EnvVar("UNKEY_PPROF_USERNAME"), - ), - cli.String( - "pprof-password", - "Password for pprof Basic Auth. Optional - if username and password are not set, pprof will be accessible without authentication.", - cli.EnvVar("UNKEY_PPROF_PASSWORD"), - ), - - // Request Body Configuration - cli.Int64("max-request-body-size", "Maximum allowed request body size in bytes. Set to 0 or negative to disable limit. Default: 10485760 (10MB)", - cli.Default(int64(10485760)), cli.EnvVar("UNKEY_MAX_REQUEST_BODY_SIZE")), - - // 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")), - - // CTRL Service Configuration - cli.String("ctrl-url", "CTRL service connection URL for deployment management. Example: http://ctrl:7091", - cli.EnvVar("UNKEY_CTRL_URL")), - cli.String("ctrl-token", "Bearer token for CTRL service authentication", - cli.EnvVar("UNKEY_CTRL_TOKEN")), + 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 { - // Check if TLS flags are properly set (both or none) - tlsCertFile := cmd.String("tls-cert-file") - tlsKeyFile := cmd.String("tls-key-file") - if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") { - return cli.Exit("Both --tls-cert-file and --tls-key-file must be provided to enable HTTPS", 1) + cfg, err := config.Load[api.Config](cmd.String("config")) + if err != nil { + return cli.Exit("Failed to load config: "+err.Error(), 1) } - // Initialize TLS config if TLS flags are provided - var tlsConfig *tls.Config - if tlsCertFile != "" && tlsKeyFile != "" { - var err error - tlsConfig, err = tls.NewFromFiles(tlsCertFile, tlsKeyFile) - if err != nil { - return cli.Exit("Failed to load TLS configuration: "+err.Error(), 1) + // Resolve TLS config from file paths + if cfg.TLS.CertFile != "" { + tlsCfg, tlsErr := tls.NewFromFiles(cfg.TLS.CertFile, cfg.TLS.KeyFile) + if tlsErr != nil { + return cli.Exit("Failed to load TLS configuration: "+tlsErr.Error(), 1) } + cfg.TLSConfig = tlsCfg } - config := api.Config{ - // Basic configuration - CacheInvalidationTopic: "", - Platform: cmd.String("platform"), - Image: cmd.String("image"), - Region: cmd.String("region"), - - // Database configuration - DatabasePrimary: cmd.String("database-primary"), - DatabaseReadonlyReplica: cmd.String("database-replica"), - - // ClickHouse - ClickhouseURL: cmd.String("clickhouse-url"), - ClickhouseAnalyticsURL: cmd.String("clickhouse-analytics-url"), - - // OpenTelemetry configuration - OtelEnabled: cmd.Bool("otel"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - - // TLS Configuration - TLSConfig: tlsConfig, - - InstanceID: cmd.String("instance-id"), - RedisUrl: cmd.String("redis-url"), - PrometheusPort: cmd.Int("prometheus-port"), - Clock: clock.New(), - TestMode: cmd.Bool("test-mode"), - - // HTTP configuration - HttpPort: cmd.Int("http-port"), - Listener: nil, // Production uses HttpPort - - // Vault configuration - VaultURL: cmd.String("vault-url"), - VaultToken: cmd.String("vault-token"), - - // Kafka configuration - KafkaBrokers: cmd.StringSlice("kafka-brokers"), - - // ClickHouse proxy configuration - ChproxyToken: cmd.String("chproxy-auth-token"), - - // CTRL service configuration - CtrlURL: cmd.String("ctrl-url"), - CtrlToken: cmd.String("ctrl-token"), - - // Profiling configuration - PprofEnabled: cmd.Bool("pprof-enabled"), - PprofUsername: cmd.String("pprof-username"), - PprofPassword: cmd.String("pprof-password"), - - // Request body configuration - MaxRequestBodySize: cmd.Int64("max-request-body-size"), - - // Logging sampler configuration - LogSampleRate: cmd.Float("log-sample-rate"), - LogSlowThreshold: cmd.Duration("log-slow-threshold"), - } - - err := config.Validate() - if err != nil { - return err - } + cfg.Clock = clock.New() - return api.Run(ctx, config) + return api.Run(ctx, cfg) } diff --git a/cmd/ctrl/BUILD.bazel b/cmd/ctrl/BUILD.bazel index 782470fb8e..afb9bce987 100644 --- a/cmd/ctrl/BUILD.bazel +++ b/cmd/ctrl/BUILD.bazel @@ -13,8 +13,8 @@ go_library( deps = [ "//pkg/cli", "//pkg/clock", + "//pkg/config", "//pkg/tls", - "//pkg/uid", "//svc/ctrl/api", "//svc/ctrl/worker", ], diff --git a/cmd/ctrl/api.go b/cmd/ctrl/api.go index 2fe98677e7..112e34f33d 100644 --- a/cmd/ctrl/api.go +++ b/cmd/ctrl/api.go @@ -2,19 +2,16 @@ package ctrl import ( "context" - "strings" "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/tls" - "github.com/unkeyed/unkey/pkg/uid" ctrlapi "github.com/unkeyed/unkey/svc/ctrl/api" ) // apiCmd defines the "api" subcommand for running the control plane HTTP server. // The server handles infrastructure management, build orchestration, and service -// coordination. It requires a MySQL database (--database-primary) and S3 storage -// for build artifacts. Optional integrations include Vault for secrets, Restate -// for workflows, and ACME for automatic TLS certificates. +// coordination. Configuration is loaded from a TOML file specified by --config. var apiCmd = &cli.Command{ Version: "", Commands: []*cli.Command{}, @@ -23,133 +20,29 @@ var apiCmd = &cli.Command{ Name: "api", Usage: "Run the Unkey control plane service for managing infrastructure and services", Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the control plane server to listen on. Default: 8080", - cli.Default(8080), cli.EnvVar("UNKEY_HTTP_PORT")), - cli.Int("prometheus-port", "Port for Prometheus metrics, set to 0 to disable.", - cli.Default(0), cli.EnvVar("UNKEY_PROMETHEUS_PORT")), - cli.Bool("color", "Enable colored log output. Default: true", - cli.Default(true), cli.EnvVar("UNKEY_LOGS_COLOR")), - - // Instance Identification - cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics.", - cli.EnvVar("UNKEY_PLATFORM")), - cli.String("region", "Geographic region identifier. Used for logging and routing. Default: unknown", - cli.Default("unknown"), cli.EnvVar("UNKEY_REGION"), cli.EnvVar("AWS_REGION")), - cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided.", - cli.Default(uid.New(uid.InstancePrefix, 4)), cli.EnvVar("UNKEY_INSTANCE_ID")), - - // Database Configuration - cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", - cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), - - // 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). Only used when --otel is provided. Default: 0.25", - cli.Default(0.25), cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), - - // TLS Configuration - cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS.", - cli.EnvVar("UNKEY_TLS_CERT_FILE")), - cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS.", - cli.EnvVar("UNKEY_TLS_KEY_FILE")), - - // Control Plane Specific - cli.String("auth-token", "Authentication token for control plane API access. Required for secure deployments.", - cli.Required(), - cli.EnvVar("UNKEY_AUTH_TOKEN")), - - // Restate Configuration - cli.String("restate-url", "URL of the Restate ingress endpoint for invoking workflows. Example: http://restate:8080", - cli.Default("http://restate:8080"), cli.EnvVar("UNKEY_RESTATE_INGRESS_URL")), - cli.String("restate-admin-url", "URL of the Restate admin API for canceling invocations. Example: http://restate:9070", - cli.Default("http://restate:9070"), cli.EnvVar("UNKEY_RESTATE_ADMIN_URL")), - cli.String("restate-api-key", "API key for Restate ingress requests", - cli.EnvVar("UNKEY_RESTATE_API_KEY")), - - cli.StringSlice("available-regions", "Available regions for deployment", cli.EnvVar("UNKEY_AVAILABLE_REGIONS"), cli.Default([]string{"local.dev"})), - - // Certificate bootstrap configuration - cli.String("default-domain", "Default domain for wildcard certificate bootstrapping (e.g., unkey.app)", cli.EnvVar("UNKEY_DEFAULT_DOMAIN")), - - cli.String("regional-domain", "Domain for cross-region communication. Per-region wildcards created as *.{region}.{domain} (e.g., unkey.cloud)", cli.EnvVar("UNKEY_REGIONAL_DOMAIN")), - - // Custom domain configuration - cli.String("cname-domain", "Base domain for custom domain CNAME targets (e.g., unkey-dns.com)", cli.Required(), cli.EnvVar("UNKEY_CNAME_DOMAIN")), - - // GitHub webhook configuration - cli.String("github-app-webhook-secret", "Secret for verifying GitHub webhook signatures", cli.EnvVar("UNKEY_GITHUB_APP_WEBHOOK_SECRET")), + cli.String("config", "Path to a TOML config file", + cli.Default("unkey.toml"), cli.EnvVar("UNKEY_CONFIG")), }, Action: apiAction, } -// apiAction validates configuration and starts the control plane API server. -// It returns an error if TLS is partially configured (only cert or only key), -// if required configuration is missing, or if the server fails to start. -// The function blocks until the context is cancelled or the server exits. +// apiAction loads configuration from a file and starts the control plane API server. +// It resolves TLS from file paths if configured and sets runtime-only fields +// before delegating to [ctrlapi.Run]. func apiAction(ctx context.Context, cmd *cli.Command) error { - // Check if TLS flags are properly set (both or none) - tlsCertFile := cmd.String("tls-cert-file") - tlsKeyFile := cmd.String("tls-key-file") - if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") { - return cli.Exit("Both --tls-cert-file and --tls-key-file must be provided to enable HTTPS", 1) + cfg, err := config.Load[ctrlapi.Config](cmd.String("config")) + if err != nil { + return cli.Exit("Failed to load config: "+err.Error(), 1) } - // Initialize TLS config if TLS flags are provided - var tlsConfig *tls.Config - if tlsCertFile != "" && tlsKeyFile != "" { - var err error - tlsConfig, err = tls.NewFromFiles(tlsCertFile, tlsKeyFile) - if err != nil { - return cli.Exit("Failed to load TLS configuration: "+err.Error(), 1) + // Resolve TLS config from file paths + if cfg.TLS.CertFile != "" { + tlsCfg, tlsErr := tls.NewFromFiles(cfg.TLS.CertFile, cfg.TLS.KeyFile) + if tlsErr != nil { + return cli.Exit("Failed to load TLS configuration: "+tlsErr.Error(), 1) } + cfg.TLSConfig = tlsCfg } - config := ctrlapi.Config{ - // Basic configuration - HttpPort: cmd.Int("http-port"), - PrometheusPort: cmd.Int("prometheus-port"), - Region: cmd.String("region"), - InstanceID: cmd.String("instance-id"), - - // Database configuration - DatabasePrimary: cmd.String("database-primary"), - - // Observability - OtelEnabled: cmd.Bool("otel"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - - // TLS Configuration - TLSConfig: tlsConfig, - - // Control Plane Specific - AuthToken: cmd.String("auth-token"), - - // Restate configuration (API is a client, only needs ingress URL) - Restate: ctrlapi.RestateConfig{ - URL: cmd.String("restate-url"), - AdminURL: cmd.RequireString("restate-admin-url"), - APIKey: cmd.String("restate-api-key"), - }, - - AvailableRegions: cmd.RequireStringSlice("available-regions"), - - // Certificate bootstrap - DefaultDomain: cmd.String("default-domain"), - RegionalDomain: cmd.String("regional-domain"), - - // Custom domain configuration - CnameDomain: strings.TrimSuffix(strings.TrimSpace(cmd.RequireString("cname-domain")), "."), - - // GitHub webhook - GitHubWebhookSecret: cmd.String("github-app-webhook-secret"), - } - - err := config.Validate() - if err != nil { - return err - } - - return ctrlapi.Run(ctx, config) + return ctrlapi.Run(ctx, cfg) } diff --git a/cmd/ctrl/worker.go b/cmd/ctrl/worker.go index ec53e5359d..7ab4d30586 100644 --- a/cmd/ctrl/worker.go +++ b/cmd/ctrl/worker.go @@ -6,14 +6,14 @@ import ( "github.com/unkeyed/unkey/pkg/cli" "github.com/unkeyed/unkey/pkg/clock" - "github.com/unkeyed/unkey/pkg/uid" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/svc/ctrl/worker" ) // workerCmd defines the "worker" subcommand for running the background job // processor. The worker handles durable workflows via Restate including container -// builds, deployments, and ACME certificate provisioning. It supports two build -// backends: "docker" for local development and "depot" for production. +// builds, deployments, and ACME certificate provisioning. Configuration is loaded +// from a TOML file specified by --config. var workerCmd = &cli.Command{ Version: "", Commands: []*cli.Command{}, @@ -22,198 +22,24 @@ var workerCmd = &cli.Command{ Name: "worker", Usage: "Run the Unkey Restate worker service for background jobs and workflows", Flags: []cli.Flag{ - // Server Configuration - cli.Int("prometheus-port", "Port for Prometheus metrics, set to 0 to disable.", - cli.Default(0), cli.EnvVar("UNKEY_PROMETHEUS_PORT")), - - // Instance Identification - cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided.", - cli.Default(uid.New(uid.InstancePrefix, 4)), cli.EnvVar("UNKEY_INSTANCE_ID")), - - // Database Configuration - cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", - cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), - - cli.String("vault-url", "Url where vault is available", - cli.Required(), - cli.EnvVar("UNKEY_VAULT_URL"), - cli.Default("https://vault.unkey.cloud"), - ), - - cli.String("vault-token", "Authentication for vault", - cli.Required(), - cli.EnvVar("UNKEY_VAULT_TOKEN"), - ), - - // Build Configuration - cli.String("build-platform", "Run builds on this platform ('dynamic', 'linux/amd64', 'linux/arm64')", - cli.Default("linux/amd64"), cli.EnvVar("UNKEY_BUILD_PLATFORM")), - - // Registry Configuration - cli.String("registry-url", "URL of the container registry for pulling images. Example: registry.depot.dev", - cli.EnvVar("UNKEY_REGISTRY_URL")), - cli.String("registry-username", "Username for authenticating with the container registry.", - cli.EnvVar("UNKEY_REGISTRY_USERNAME")), - cli.String("registry-password", "Password/token for authenticating with the container registry.", - cli.EnvVar("UNKEY_REGISTRY_PASSWORD")), - - // Depot Build Backend Configuration - cli.String("depot-api-url", "Depot API endpoint URL", - cli.EnvVar("UNKEY_DEPOT_API_URL")), - cli.String("depot-project-region", "Build data will be stored in the chosen region ('us-east-1','eu-central-1')", - cli.EnvVar("UNKEY_DEPOT_PROJECT_REGION"), cli.Default("us-east-1")), - - // ACME Configuration - cli.Bool("acme-enabled", "Enable Let's Encrypt for acme challenges", cli.EnvVar("UNKEY_ACME_ENABLED")), - cli.String("acme-email-domain", "Domain for ACME registration emails (workspace_id@domain)", cli.Default("unkey.com"), cli.EnvVar("UNKEY_ACME_EMAIL_DOMAIN")), - - // Route53 DNS provider - cli.Bool("acme-route53-enabled", "Enable Route53 for DNS-01 challenges", cli.EnvVar("UNKEY_ACME_ROUTE53_ENABLED")), - cli.String("acme-route53-access-key-id", "AWS access key ID for Route53", cli.EnvVar("UNKEY_ACME_ROUTE53_ACCESS_KEY_ID")), - cli.String("acme-route53-secret-access-key", "AWS secret access key for Route53", cli.EnvVar("UNKEY_ACME_ROUTE53_SECRET_ACCESS_KEY")), - cli.String("acme-route53-region", "AWS region for Route53", cli.Default("us-east-1"), cli.EnvVar("UNKEY_ACME_ROUTE53_REGION")), - cli.String("acme-route53-hosted-zone-id", "Route53 hosted zone ID (bypasses auto-discovery, required when wildcard CNAMEs exist)", cli.EnvVar("UNKEY_ACME_ROUTE53_HOSTED_ZONE_ID")), - - cli.String("default-domain", "Default domain for auto-generated hostnames", cli.Default("unkey.app"), cli.EnvVar("UNKEY_DEFAULT_DOMAIN")), - cli.String("cname-domain", "Base domain for custom domain CNAME targets (e.g., unkey-dns.com)", cli.Required(), cli.EnvVar("UNKEY_CNAME_DOMAIN")), - - // Restate Configuration - cli.String("restate-admin-url", "URL of the Restate admin endpoint for service registration. Example: http://restate:9070", - cli.Default("http://restate:9070"), cli.EnvVar("UNKEY_RESTATE_ADMIN_URL")), - cli.String("restate-api-key", "API key for Restate admin API requests", - cli.EnvVar("UNKEY_RESTATE_API_KEY")), - cli.Int("restate-http-port", "Port where we listen for Restate HTTP requests. Example: 9080", - cli.Default(9080), cli.EnvVar("UNKEY_RESTATE_HTTP_PORT")), - cli.String("restate-register-as", "URL of this service for self-registration with Restate. Example: http://worker:9080", - cli.EnvVar("UNKEY_RESTATE_REGISTER_AS")), - - // ClickHouse Configuration - cli.String("clickhouse-url", "ClickHouse connection string for analytics. Required. Example: clickhouse://user:pass@host:9000/unkey", - cli.EnvVar("UNKEY_CLICKHOUSE_URL")), - cli.String("clickhouse-admin-url", "ClickHouse admin connection string for user provisioning. Optional. Example: clickhouse://unkey_user_admin:password@host:9000/default", - cli.EnvVar("UNKEY_CLICKHOUSE_ADMIN_URL")), - - // Sentinel configuration - cli.String("sentinel-image", "The image new sentinels get deployed with", cli.Default("ghcr.io/unkeyed/unkey:local"), cli.EnvVar("UNKEY_SENTINEL_IMAGE")), - cli.StringSlice("available-regions", "Available regions for deployment", cli.EnvVar("UNKEY_AVAILABLE_REGIONS"), cli.Default([]string{"local.dev"})), - - // GitHub App Configuration - cli.Int64("github-app-id", "GitHub App ID for webhook-triggered deployments", cli.EnvVar("UNKEY_GITHUB_APP_ID")), - cli.String("github-private-key-pem", "GitHub App private key in PEM format", cli.EnvVar("UNKEY_GITHUB_PRIVATE_KEY_PEM")), - cli.Bool("allow-unauthenticated-deployments", "Allow deployments without GitHub authentication. Enable only for local dev.", cli.Default(false), cli.EnvVar("UNKEY_ALLOW_UNAUTHENTICATED_DEPLOYMENTS")), - - // Healthcheck heartbeat URLs - cli.String("cert-renewal-heartbeat-url", "Checkly heartbeat URL for certificate renewal", cli.EnvVar("UNKEY_CERT_RENEWAL_HEARTBEAT_URL")), - cli.String("quota-check-heartbeat-url", "Checkly heartbeat URL for quota checks", cli.EnvVar("UNKEY_QUOTA_CHECK_HEARTBEAT_URL")), - cli.String("key-refill-heartbeat-url", "Checkly heartbeat URL for key refills", cli.EnvVar("UNKEY_KEY_REFILL_HEARTBEAT_URL")), - - // Slack notifications - cli.String("quota-check-slack-webhook-url", "Slack webhook URL for quota exceeded notifications", cli.EnvVar("UNKEY_QUOTA_CHECK_SLACK_WEBHOOK_URL")), - - // Observability - cli.Bool("otel-enabled", "Enable OpenTelemetry tracing and logging", - cli.Default(false), - cli.EnvVar("UNKEY_OTEL_ENABLED")), - cli.Float("otel-trace-sampling-rate", "Sampling rate for traces (0.0 to 1.0)", - cli.Default(0.01), - cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), - cli.String("region", "Cloud region identifier", - cli.EnvVar("UNKEY_REGION")), + cli.String("config", "Path to a TOML config file", + cli.Default("unkey.toml"), cli.EnvVar("UNKEY_CONFIG")), }, Action: workerAction, } -// workerAction validates configuration and starts the background worker service. -// It returns an error if required configuration is missing or if the worker fails -// to start. The function blocks until the context is cancelled or the worker exits. +// workerAction loads configuration from a file and starts the background worker +// service. It sets runtime-only fields before delegating to [worker.Run]. func workerAction(ctx context.Context, cmd *cli.Command) error { - config := worker.Config{ - // Basic configuration - PrometheusPort: cmd.Int("prometheus-port"), - InstanceID: cmd.String("instance-id"), - - // Database configuration - DatabasePrimary: cmd.String("database-primary"), - - // Vault configuration - VaultURL: cmd.String("vault-url"), - VaultToken: cmd.String("vault-token"), - - // Build configuration - BuildPlatform: cmd.String("build-platform"), - - // Registry configuration - RegistryURL: cmd.String("registry-url"), - RegistryUsername: cmd.String("registry-username"), - RegistryPassword: cmd.String("registry-password"), - - // Depot build backend configuration - Depot: worker.DepotConfig{ - APIUrl: cmd.String("depot-api-url"), - ProjectRegion: cmd.String("depot-project-region"), - }, - - // Acme configuration - Acme: worker.AcmeConfig{ - Enabled: cmd.Bool("acme-enabled"), - EmailDomain: cmd.String("acme-email-domain"), - Route53: worker.Route53Config{ - Enabled: cmd.Bool("acme-route53-enabled"), - AccessKeyID: cmd.String("acme-route53-access-key-id"), - SecretAccessKey: cmd.String("acme-route53-secret-access-key"), - Region: cmd.String("acme-route53-region"), - HostedZoneID: cmd.String("acme-route53-hosted-zone-id"), - }, - }, - - DefaultDomain: cmd.String("default-domain"), - - // Restate configuration - Restate: worker.RestateConfig{ - AdminURL: cmd.String("restate-admin-url"), - APIKey: cmd.String("restate-api-key"), - HttpPort: cmd.Int("restate-http-port"), - RegisterAs: cmd.String("restate-register-as"), - }, - - // Clickhouse Configuration - ClickhouseURL: cmd.String("clickhouse-url"), - ClickhouseAdminURL: cmd.String("clickhouse-admin-url"), - - // Sentinel configuration - SentinelImage: cmd.String("sentinel-image"), - AvailableRegions: cmd.RequireStringSlice("available-regions"), - - // GitHub configuration - GitHub: worker.GitHubConfig{ - AppID: cmd.Int64("github-app-id"), - PrivateKeyPEM: cmd.String("github-private-key-pem"), - }, - AllowUnauthenticatedDeployments: cmd.Bool("allow-unauthenticated-deployments"), - - // Custom domain configuration - CnameDomain: strings.TrimSuffix(strings.TrimSpace(cmd.RequireString("cname-domain")), "."), - - Clock: clock.New(), - - // Healthcheck heartbeat URLs - CertRenewalHeartbeatURL: cmd.String("cert-renewal-heartbeat-url"), - QuotaCheckHeartbeatURL: cmd.String("quota-check-heartbeat-url"), - KeyRefillHeartbeatURL: cmd.String("key-refill-heartbeat-url"), - - // Slack notifications - QuotaCheckSlackWebhookURL: cmd.String("quota-check-slack-webhook-url"), - - // Observability - OtelEnabled: cmd.Bool("otel-enabled"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - Region: cmd.String("region"), - } - - err := config.Validate() + cfg, err := config.Load[worker.Config](cmd.String("config")) if err != nil { - return err + return cli.Exit("Failed to load config: "+err.Error(), 1) } - return worker.Run(ctx, config) + // Normalize CNAME domain: trim whitespace and trailing dot + cfg.CnameDomain = strings.TrimSuffix(strings.TrimSpace(cfg.CnameDomain), ".") + + cfg.Clock = clock.New() + + return worker.Run(ctx, cfg) } diff --git a/cmd/frontline/BUILD.bazel b/cmd/frontline/BUILD.bazel index 88be1fb428..24d7894dba 100644 --- a/cmd/frontline/BUILD.bazel +++ b/cmd/frontline/BUILD.bazel @@ -7,6 +7,7 @@ go_library( visibility = ["//visibility:public"], deps = [ "//pkg/cli", + "//pkg/config", "//pkg/uid", "//svc/frontline", ], diff --git a/cmd/frontline/main.go b/cmd/frontline/main.go index 7023a1aeda..4ab72a3bdb 100644 --- a/cmd/frontline/main.go +++ b/cmd/frontline/main.go @@ -2,9 +2,9 @@ package frontline import ( "context" - "time" "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/uid" "github.com/unkeyed/unkey/svc/frontline" ) @@ -19,114 +19,19 @@ var Cmd = &cli.Command{ Name: "frontline", Usage: "Run the Unkey Frontline server (multi-tenant frontline)", Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the Gate server to listen on. Default: 7070", - cli.Default(7070), cli.EnvVar("UNKEY_HTTP_PORT")), - - cli.Int("https-port", "HTTPS port for the Gate server to listen on. Default: 7443", - cli.Default(7443), cli.EnvVar("UNKEY_HTTPS_PORT")), - - cli.Bool("tls-enabled", "Enable TLS termination for the frontline. Default: true", - cli.Default(true), cli.EnvVar("UNKEY_TLS_ENABLED")), - - cli.String("tls-cert-file", "Path to TLS certificate file (dev mode)", - cli.EnvVar("UNKEY_TLS_CERT_FILE")), - - cli.String("tls-key-file", "Path to TLS key file (dev mode)", - cli.EnvVar("UNKEY_TLS_KEY_FILE")), - - cli.String("region", "The cloud region with platform, e.g. us-east-1.aws", - cli.Required(), - cli.EnvVar("UNKEY_REGION"), - ), - - cli.String("frontline-id", "Unique identifier for this instance. Auto-generated if not provided.", - cli.Default(uid.New("frontline", 4)), cli.EnvVar("UNKEY_GATE_ID")), - - cli.String("default-cert-domain", "Domain to use for fallback TLS certificate when a domain has no cert configured", - cli.EnvVar("UNKEY_DEFAULT_CERT_DOMAIN")), - - cli.String("apex-domain", "Apex domain for region routing. Cross-region requests forwarded to frontline.{region}.{apex-domain}. Example: unkey.cloud", - cli.Default("unkey.cloud"), cli.EnvVar("UNKEY_APEX_DOMAIN")), - - // Database Configuration - Partitioned (for hostname lookups) - cli.String("database-primary", "MySQL connection string for partitioned primary database (frontline operations). Required. Example: user:pass@host:3306/unkey?parseTime=true", - cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), - - cli.String("database-replica", "MySQL connection string for partitioned read-replica (frontline operations). Format same as database-primary.", - cli.EnvVar("UNKEY_DATABASE_REPLICA")), - - // 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). Only used when --otel is provided. 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")), - - // Vault Configuration - cli.String("vault-url", "URL of the remote vault service (e.g., http://vault:8080)", - cli.EnvVar("UNKEY_VAULT_URL")), - cli.String("vault-token", "Authentication token for the vault service", - cli.EnvVar("UNKEY_VAULT_TOKEN")), - - cli.Int("max-hops", "Maximum number of hops allowed for a request", - cli.Default(10), cli.EnvVar("UNKEY_MAX_HOPS")), - - cli.String("ctrl-addr", "Address of the control plane", - cli.Default("localhost:8080"), cli.EnvVar("UNKEY_CTRL_ADDR")), - - // 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 { - config := frontline.Config{ - // Basic configuration - FrontlineID: cmd.String("frontline-id"), - Image: cmd.String("image"), - Region: cmd.String("region"), - - // HTTP configuration - HttpPort: cmd.Int("http-port"), - HttpsPort: cmd.Int("https-port"), - - // TLS configuration - EnableTLS: cmd.Bool("tls-enabled"), - TLSCertFile: cmd.String("tls-cert-file"), - TLSKeyFile: cmd.String("tls-key-file"), - ApexDomain: cmd.String("apex-domain"), - MaxHops: cmd.Int("max-hops"), - - // Control Plane Configuration - CtrlAddr: cmd.String("ctrl-addr"), - - // Partitioned Database configuration (for hostname lookups) - DatabasePrimary: cmd.String("database-primary"), - DatabaseReadonlyReplica: cmd.String("database-replica"), - - // OpenTelemetry configuration - OtelEnabled: cmd.Bool("otel"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - PrometheusPort: cmd.Int("prometheus-port"), - - // Vault configuration - VaultURL: cmd.String("vault-url"), - VaultToken: cmd.String("vault-token"), - - // Logging sampler configuration - LogSampleRate: cmd.Float("log-sample-rate"), - LogSlowThreshold: cmd.Duration("log-slow-threshold"), - } - - err := config.Validate() + cfg, err := config.Load[frontline.Config](cmd.String("config")) if err != nil { - return err + return cli.Exit("Failed to load config: "+err.Error(), 1) } - return frontline.Run(ctx, config) + cfg.FrontlineID = uid.New("frontline", 4) + + return frontline.Run(ctx, cfg) } diff --git a/cmd/krane/BUILD.bazel b/cmd/krane/BUILD.bazel index 9a4d2d0fd0..70eefc9770 100644 --- a/cmd/krane/BUILD.bazel +++ b/cmd/krane/BUILD.bazel @@ -7,7 +7,8 @@ go_library( visibility = ["//visibility:public"], deps = [ "//pkg/cli", - "//pkg/uid", + "//pkg/clock", + "//pkg/config", "//svc/krane", ], ) diff --git a/cmd/krane/main.go b/cmd/krane/main.go index 2ea11683d3..b469c8a4e6 100644 --- a/cmd/krane/main.go +++ b/cmd/krane/main.go @@ -2,10 +2,10 @@ package krane import ( "context" - "time" "github.com/unkeyed/unkey/pkg/cli" - "github.com/unkeyed/unkey/pkg/uid" + "github.com/unkeyed/unkey/pkg/clock" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/svc/krane" ) @@ -21,113 +21,21 @@ var Cmd = &cli.Command{ It manages the lifecycle of deployments in a kubernetes cluster: EXAMPLES: -unkey run krane # Run with default configuration`, + unkey run krane --config /etc/unkey/krane.toml`, Flags: []cli.Flag{ - // Server Configuration - cli.String("control-plane-url", - "URL of the control plane to connect to", - cli.Default("https://control.unkey.cloud"), - cli.EnvVar("UNKEY_CONTROL_PLANE_URL"), - ), - cli.String("control-plane-bearer", - "Bearer token for authenticating with the control plane", - cli.Default(""), - cli.EnvVar("UNKEY_CONTROL_PLANE_BEARER"), - ), - - // Instance Identification - cli.String("instance-id", - "Unique identifier for this instance. Auto-generated if not provided.", - cli.Default(uid.New(uid.InstancePrefix, 4)), - cli.EnvVar("UNKEY_INSTANCE_ID"), - ), - cli.String("region", - "The cloud region with platform, e.g. us-east-1.aws", - cli.Required(), - cli.EnvVar("UNKEY_REGION"), - ), - - cli.String("registry-url", - "URL of the container registry for pulling images. Example: registry.depot.dev", - cli.EnvVar("UNKEY_REGISTRY_URL"), - ), - - cli.String("registry-username", - "Username for authenticating with the container registry.", - cli.EnvVar("UNKEY_REGISTRY_USERNAME"), - ), - - cli.String("registry-password", - "Password/token for authenticating with the container registry.", - cli.EnvVar("UNKEY_REGISTRY_PASSWORD"), - ), - - cli.Int("prometheus-port", - "Port for Prometheus metrics, set to 0 to disable.", - cli.Default(0), - cli.EnvVar("UNKEY_PROMETHEUS_PORT")), - - cli.Int("rpc-port", - "Port for RPC server", - cli.Default(8070), - cli.EnvVar("UNKEY_RPC_PORT")), - - // Vault Configuration - cli.String("vault-url", "URL of the vault service", - cli.EnvVar("UNKEY_VAULT_URL")), - cli.String("vault-token", "Authentication token for the vault service", - cli.EnvVar("UNKEY_VAULT_TOKEN")), - - cli.String("cluster-id", "ID of the cluster", - cli.Default("local"), - cli.EnvVar("UNKEY_CLUSTER_ID")), - - // Observability - cli.Bool("otel-enabled", "Enable OpenTelemetry tracing and logging", - cli.Default(false), - cli.EnvVar("UNKEY_OTEL_ENABLED")), - cli.Float("otel-trace-sampling-rate", "Sampling rate for traces (0.0 to 1.0)", - cli.Default(0.01), - cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), - - // 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 { - - config := krane.Config{ - Clock: nil, - Region: cmd.RequireString("region"), - InstanceID: cmd.RequireString("instance-id"), - RegistryURL: cmd.RequireString("registry-url"), - RegistryUsername: cmd.RequireString("registry-username"), - RegistryPassword: cmd.RequireString("registry-password"), - RPCPort: cmd.RequireInt("rpc-port"), - VaultURL: cmd.String("vault-url"), - VaultToken: cmd.String("vault-token"), - PrometheusPort: cmd.RequireInt("prometheus-port"), - ControlPlaneURL: cmd.RequireString("control-plane-url"), - ControlPlaneBearer: cmd.RequireString("control-plane-bearer"), - OtelEnabled: cmd.Bool("otel-enabled"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - - // Logging sampler configuration - LogSampleRate: cmd.Float("log-sample-rate"), - LogSlowThreshold: cmd.Duration("log-slow-threshold"), - } - - // Validate configuration - err := config.Validate() + cfg, err := config.Load[krane.Config](cmd.String("config")) if err != nil { - return cli.Exit("Invalid configuration: "+err.Error(), 1) + return cli.Exit("Failed to load config: "+err.Error(), 1) } - // Run krane - return krane.Run(ctx, config) + cfg.Clock = clock.New() + + return krane.Run(ctx, cfg) } diff --git a/cmd/preflight/BUILD.bazel b/cmd/preflight/BUILD.bazel index b8258daea5..e3a76240ba 100644 --- a/cmd/preflight/BUILD.bazel +++ b/cmd/preflight/BUILD.bazel @@ -7,6 +7,7 @@ go_library( visibility = ["//visibility:public"], deps = [ "//pkg/cli", + "//pkg/config", "//svc/preflight", ], ) diff --git a/cmd/preflight/main.go b/cmd/preflight/main.go index b82a24456c..fac40ac53a 100644 --- a/cmd/preflight/main.go +++ b/cmd/preflight/main.go @@ -2,9 +2,9 @@ package preflight import ( "context" - "time" "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/svc/preflight" ) @@ -14,52 +14,17 @@ var Cmd = &cli.Command{ Name: "preflight", Usage: "Run the pod mutation webhook for secrets and credentials injection", Flags: []cli.Flag{ - cli.Int("http-port", "HTTP port for the webhook server. Default: 8443", - cli.Default(8443), cli.EnvVar("UNKEY_HTTP_PORT")), - cli.String("tls-cert-file", "Path to TLS certificate file", - cli.Required(), cli.EnvVar("UNKEY_TLS_CERT_FILE")), - cli.String("tls-key-file", "Path to TLS private key file", - cli.Required(), cli.EnvVar("UNKEY_TLS_KEY_FILE")), - cli.String("inject-image", "Container image for inject binary", - cli.Default("inject:latest"), cli.EnvVar("UNKEY_INJECT_IMAGE")), - cli.String("inject-image-pull-policy", "Image pull policy (Always, IfNotPresent, Never)", - cli.Default("IfNotPresent"), cli.EnvVar("UNKEY_INJECT_IMAGE_PULL_POLICY")), - cli.String("krane-endpoint", "Endpoint for Krane secrets service", - cli.Default("http://krane.unkey.svc.cluster.local:8070"), cli.EnvVar("UNKEY_KRANE_ENDPOINT")), - cli.String("depot-token", "Depot API token for fetching on-demand pull tokens (optional)", - cli.EnvVar("UNKEY_DEPOT_TOKEN")), - cli.StringSlice("insecure-registries", "Comma-separated list of insecure (HTTP) registries", - cli.EnvVar("UNKEY_INSECURE_REGISTRIES")), - cli.StringSlice("registry-aliases", "Comma-separated list of registry aliases (from=to)", - cli.EnvVar("UNKEY_REGISTRY_ALIASES")), - // 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 { - config := preflight.Config{ - HttpPort: cmd.Int("http-port"), - TLSCertFile: cmd.RequireString("tls-cert-file"), - TLSKeyFile: cmd.RequireString("tls-key-file"), - InjectImage: cmd.String("inject-image"), - InjectImagePullPolicy: cmd.String("inject-image-pull-policy"), - KraneEndpoint: cmd.String("krane-endpoint"), - DepotToken: cmd.String("depot-token"), - InsecureRegistries: cmd.StringSlice("insecure-registries"), - RegistryAliases: cmd.StringSlice("registry-aliases"), - // Logging sampler configuration - LogSampleRate: cmd.Float("log-sample-rate"), - LogSlowThreshold: cmd.Duration("log-slow-threshold"), + cfg, err := config.Load[preflight.Config](cmd.String("config")) + if err != nil { + return cli.Exit("Failed to load config: "+err.Error(), 1) } - if err := config.Validate(); err != nil { - return cli.Exit("Invalid configuration: "+err.Error(), 1) - } - - return preflight.Run(ctx, config) + return preflight.Run(ctx, cfg) } 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/cmd/vault/BUILD.bazel b/cmd/vault/BUILD.bazel index 5b32400318..7010ae4537 100644 --- a/cmd/vault/BUILD.bazel +++ b/cmd/vault/BUILD.bazel @@ -7,7 +7,7 @@ go_library( visibility = ["//visibility:public"], deps = [ "//pkg/cli", - "//pkg/uid", + "//pkg/config", "//svc/vault", ], ) diff --git a/cmd/vault/main.go b/cmd/vault/main.go index 235953ddfe..bcf46216d8 100644 --- a/cmd/vault/main.go +++ b/cmd/vault/main.go @@ -2,10 +2,9 @@ package vault 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/vault" ) @@ -19,80 +18,17 @@ var Cmd = &cli.Command{ Name: "vault", Usage: "Run unkey's encryption service", Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the control plane server to listen on. Default: 8080", - cli.Default(8060), cli.EnvVar("UNKEY_HTTP_PORT")), - - // Instance Identification - cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided.", - cli.Default(uid.New(uid.InstancePrefix, 4)), cli.EnvVar("UNKEY_INSTANCE_ID")), - - cli.String("bearer-token", "Authentication token for API access.", - cli.Required(), - cli.EnvVar("UNKEY_BEARER_TOKEN")), - - // Vault Configuration - General secrets (env vars, API keys) - cli.StringSlice("master-keys", "Vault master keys for encryption (general vault)", - cli.Required(), cli.EnvVar("UNKEY_MASTER_KEYS")), - cli.String("s3-url", "S3 endpoint URL for general vault", - cli.Required(), - cli.EnvVar("UNKEY_S3_URL")), - cli.String("s3-bucket", "S3 bucket for general vault (env vars, API keys)", - cli.Required(), - cli.EnvVar("UNKEY_S3_BUCKET")), - cli.String("s3-access-key-id", "S3 access key ID for general vault", - cli.Required(), - cli.EnvVar("UNKEY_S3_ACCESS_KEY_ID")), - cli.String("s3-access-key-secret", "S3 secret access key for general vault", - cli.Required(), - cli.EnvVar("UNKEY_S3_ACCESS_KEY_SECRET")), - - // Observability - cli.Bool("otel-enabled", "Enable OpenTelemetry tracing and logging", - cli.Default(false), - cli.EnvVar("UNKEY_OTEL_ENABLED")), - cli.Float("otel-trace-sampling-rate", "Sampling rate for traces (0.0 to 1.0)", - cli.Default(0.01), - cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), - cli.String("region", "Cloud region identifier", - cli.EnvVar("UNKEY_REGION")), - - // 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 { - - config := vault.Config{ - // Basic configuration - HttpPort: cmd.RequireInt("http-port"), - InstanceID: cmd.RequireString("instance-id"), - S3URL: cmd.RequireString("s3-url"), - S3Bucket: cmd.RequireString("s3-bucket"), - S3AccessKeyID: cmd.RequireString("s3-access-key-id"), - S3AccessKeySecret: cmd.RequireString("s3-access-key-secret"), - MasterKeys: cmd.RequireStringSlice("master-keys"), - BearerToken: cmd.RequireString("bearer-token"), - - // Observability - OtelEnabled: cmd.Bool("otel-enabled"), - OtelTraceSamplingRate: cmd.Float("otel-trace-sampling-rate"), - Region: cmd.String("region"), - - // Logging sampler configuration - LogSampleRate: cmd.Float("log-sample-rate"), - LogSlowThreshold: cmd.Duration("log-slow-threshold"), - } - - err := config.Validate() + cfg, err := config.Load[vault.Config](cmd.String("config")) if err != nil { - return err + return cli.Exit("Failed to load config: "+err.Error(), 1) } - return vault.Run(ctx, config) + return vault.Run(ctx, cfg) } diff --git a/dev/config/api.toml b/dev/config/api.toml new file mode 100644 index 0000000000..fd20cc8ba1 --- /dev/null +++ b/dev/config/api.toml @@ -0,0 +1,29 @@ +http_port = 7070 +region = "local" +redis_url = "redis://redis:6379" + +[database] +primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true" + +[clickhouse] +url = "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" +analytics_url = "http://clickhouse:8123/default" +proxy_token = "chproxy-test-token-123" + +[otel] +enabled = false + +[vault] +url = "http://vault:8060" +token = "vault-test-token-123" + +[kafka] +brokers = ["kafka:9092"] + +[ctrl] +url = "http://ctrl-api:7091" +token = "your-local-dev-key" + +[pprof] +username = "admin" +password = "password" diff --git a/dev/config/ctrl-api.toml b/dev/config/ctrl-api.toml new file mode 100644 index 0000000000..3ea170c72e --- /dev/null +++ b/dev/config/ctrl-api.toml @@ -0,0 +1,20 @@ +instance_id = "ctrl-api-dev" +region = "local" +http_port = 7091 +auth_token = "your-local-dev-key" +available_regions = ["local.dev"] +default_domain = "unkey.local" +cname_domain = "unkey.local" + +[database] +primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + +[otel] +enabled = false + +[restate] +url = "http://restate:8080" +admin_url = "http://restate:9070" + +[github] +webhook_secret = "${UNKEY_GITHUB_APP_WEBHOOK_SECRET}" diff --git a/dev/config/ctrl-worker.toml b/dev/config/ctrl-worker.toml new file mode 100644 index 0000000000..0b2f6a7661 --- /dev/null +++ b/dev/config/ctrl-worker.toml @@ -0,0 +1,37 @@ +instance_id = "ctrl-worker-dev" +region = "local" +default_domain = "unkey.local" +cname_domain = "unkey.local" +build_platform = "linux/amd64" +sentinel_image = "unkey/sentinel:latest" + +[database] +primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + +[vault] +url = "http://vault:8060" +token = "vault-test-token-123" + +[otel] +enabled = false + +[restate] +admin_url = "http://restate:9070" +http_port = 9080 +register_as = "http://ctrl-worker:9080" + +[registry] +url = "registry.depot.dev" +username = "x-token" +password = "${UNKEY_REGISTRY_PASSWORD}" + +[depot] +api_url = "https://api.depot.dev" +project_region = "us-east-1" + +[acme] +enabled = false + +[clickhouse] +url = "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" +admin_url = "clickhouse://unkey_user_admin:C57RqT5EPZBqCJkMxN9mEZZEzMPcw9yBlwhIizk99t7kx6uLi9rYmtWObsXzdl@clickhouse:9000?secure=false&skip_verify=true" diff --git a/dev/config/krane.toml b/dev/config/krane.toml new file mode 100644 index 0000000000..915dbd841f --- /dev/null +++ b/dev/config/krane.toml @@ -0,0 +1,17 @@ +region = "local.dev" + +[control_plane] +url = "http://ctrl-api:7091" +bearer = "your-local-dev-key" + +[vault] +url = "http://vault:8060" +token = "vault-test-token-123" + +[registry] +url = "${UNKEY_REGISTRY_URL}" +username = "${UNKEY_REGISTRY_USERNAME}" +password = "${UNKEY_REGISTRY_PASSWORD}" + +[otel] +enabled = false diff --git a/dev/config/vault.toml b/dev/config/vault.toml new file mode 100644 index 0000000000..f29482f753 --- /dev/null +++ b/dev/config/vault.toml @@ -0,0 +1,13 @@ +instance_id = "vault-dev" +http_port = 8060 +bearer_token = "vault-test-token-123" +master_keys = ["Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U="] + +[s3] +url = "http://s3:3902" +bucket = "vault" +access_key_id = "minio_root_user" +access_key_secret = "minio_root_password" + +[otel] +enabled = false 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/dev/k8s/manifests/api.yaml b/dev/k8s/manifests/api.yaml index f179e7989a..f00e7886a4 100644 --- a/dev/k8s/manifests/api.yaml +++ b/dev/k8s/manifests/api.yaml @@ -1,3 +1,40 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: api-config + namespace: unkey +data: + unkey.toml: | + instance_id = "api-dev" + platform = "kubernetes" + image = "unkey:local" + region = "local" + http_port = 7070 + redis_url = "redis://redis:6379" + test_mode = false + prometheus_port = 0 + max_request_body_size = 10485760 + + [database] + primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + + [clickhouse] + url = "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" + analytics_url = "http://clickhouse:8123/default" + proxy_token = "chproxy-test-token-123" + + [otel] + enabled = false + + [vault] + url = "http://vault:8060" + token = "vault-test-token-123" + + [ctrl] + url = "http://ctrl-api:7091" + token = "your-local-dev-key" + --- apiVersion: apps/v1 kind: Deployment @@ -19,58 +56,14 @@ spec: containers: - name: api image: unkey/go:latest - args: ["run", "api"] + args: ["run", "api", "--config", "/etc/unkey/unkey.toml"] imagePullPolicy: Never # Use local images ports: - containerPort: 7070 - env: - # Server Configuration - - name: UNKEY_HTTP_PORT - value: "7070" - - name: UNKEY_LOGS_COLOR - value: "true" - - name: UNKEY_TEST_MODE - value: "false" - # Instance Identification - - name: UNKEY_PLATFORM - value: "kubernetes" - - name: UNKEY_IMAGE - value: "unkey:local" - - name: UNKEY_REGION - value: "local" - - name: UNKEY_INSTANCE_ID - value: "api-dev" - # Database Configuration - - name: UNKEY_DATABASE_PRIMARY - value: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" - # Caching and Storage - - name: UNKEY_REDIS_URL - value: "redis://redis:6379" - - name: UNKEY_CLICKHOUSE_URL - value: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" - - name: UNKEY_CLICKHOUSE_ANALYTICS_URL - value: "http://clickhouse:8123/default" - # Observability - DISABLED for development - - name: UNKEY_OTEL - value: "false" - - name: UNKEY_PROMETHEUS_PORT - value: "0" - # Vault Configuration - - name: UNKEY_VAULT_URL - value: "http://vault:8060" - - name: UNKEY_VAULT_TOKEN - value: "vault-test-token-123" - # ClickHouse Proxy Service Configuration - - name: UNKEY_CHPROXY_AUTH_TOKEN - value: "chproxy-test-token-123" - # Control Plane Configuration - - name: UNKEY_CTRL_URL - value: "http://ctrl-api:7091" - - name: UNKEY_CTRL_TOKEN - value: "your-local-dev-key" - # Request Body Configuration - - name: UNKEY_MAX_REQUEST_BODY_SIZE - value: "10485760" + volumeMounts: + - name: config + mountPath: /etc/unkey + readOnly: true readinessProbe: httpGet: path: /health/ready @@ -83,6 +76,10 @@ spec: port: 7070 initialDelaySeconds: 30 periodSeconds: 10 + volumes: + - name: config + configMap: + name: api-config initContainers: - name: wait-for-dependencies image: busybox:1.36 diff --git a/dev/k8s/manifests/ctrl-api.yaml b/dev/k8s/manifests/ctrl-api.yaml index 525977bc0f..5b6f48111f 100644 --- a/dev/k8s/manifests/ctrl-api.yaml +++ b/dev/k8s/manifests/ctrl-api.yaml @@ -1,3 +1,34 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ctrl-api-config + namespace: unkey + labels: + app: ctrl-api +data: + unkey.toml: | + instance_id = "ctrl-api-dev" + region = "local" + http_port = 7091 + auth_token = "your-local-dev-key" + available_regions = ["local.dev"] + default_domain = "unkey.local" + cname_domain = "unkey.local" + + [database] + primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + + [otel] + enabled = false + + [restate] + url = "http://restate:8080" + admin_url = "http://restate:9070" + + [github] + webhook_secret = "${UNKEY_GITHUB_APP_WEBHOOK_SECRET}" + --- apiVersion: apps/v1 kind: Deployment @@ -20,7 +51,7 @@ spec: containers: - name: ctrl-api image: unkey/go:latest - args: ["run", "ctrl", "api"] + args: ["run", "ctrl", "api", "--config", "/etc/unkey/unkey.toml"] imagePullPolicy: Never # Use local images ports: - containerPort: 7091 @@ -37,58 +68,20 @@ spec: initialDelaySeconds: 10 periodSeconds: 10 env: - # Server Configuration - - name: UNKEY_HTTP_PORT - value: "7091" - - name: UNKEY_LOGS_COLOR - value: "true" - # Instance Identification - - name: UNKEY_PLATFORM - value: "kubernetes" - - name: UNKEY_REGION - value: "local" - - name: UNKEY_INSTANCE_ID - value: "ctrl-api-dev" - # Database Configuration - - name: UNKEY_DATABASE_PRIMARY - value: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" - - # Observability - DISABLED for development - - name: UNKEY_OTEL - value: "false" - - #kubectl create secret docker-registry depot-registry \ - # --docker-server=registry.depot.dev \ - # --docker-username=x-token \ - # --docker-password=xxx \ - # --namespace=unkey - - # Restate Configuration (ctrl-api only needs ingress client) - - name: UNKEY_RESTATE_INGRESS_URL - value: "http://restate:8080" - - name: UNKEY_RESTATE_ADMIN_URL - value: "http://restate:9070" - - name: UNKEY_RESTATE_API_KEY - value: "" - - # API Key - - name: UNKEY_AUTH_TOKEN - value: "your-local-dev-key" - - # Certificate bootstrap - - name: UNKEY_DEFAULT_DOMAIN - value: "unkey.local" - - name: UNKEY_CNAME_DOMAIN - value: "unkey.local" - - # GitHub webhook (optional) - name: UNKEY_GITHUB_APP_WEBHOOK_SECRET valueFrom: secretKeyRef: name: github-credentials key: UNKEY_GITHUB_APP_WEBHOOK_SECRET optional: true - + volumeMounts: + - name: config + mountPath: /etc/unkey + readOnly: true + volumes: + - name: config + configMap: + name: ctrl-api-config initContainers: - name: wait-for-dependencies image: busybox:1.36 diff --git a/dev/k8s/manifests/ctrl-worker.yaml b/dev/k8s/manifests/ctrl-worker.yaml index 4a6ebfc5e9..3dbd99b42f 100644 --- a/dev/k8s/manifests/ctrl-worker.yaml +++ b/dev/k8s/manifests/ctrl-worker.yaml @@ -1,3 +1,57 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ctrl-worker-config + namespace: unkey + labels: + app: ctrl-worker +data: + unkey.toml: | + instance_id = "worker-dev" + region = "local" + default_domain = "unkey.local" + cname_domain = "unkey.local" + build_platform = "linux/arm64" + sentinel_image = "unkey/sentinel:latest" + available_regions = ["local.dev"] + allow_unauthenticated_deployments = false + + [database] + primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + + [vault] + url = "http://vault:8060" + token = "${UNKEY_VAULT_TOKEN}" + + [otel] + enabled = false + + [restate] + admin_url = "http://restate:9070" + http_port = 9080 + register_as = "http://ctrl-worker:9080" + + [registry] + url = "registry.depot.dev" + username = "x-token" + password = "${UNKEY_REGISTRY_PASSWORD}" + + [depot] + api_url = "https://api.depot.dev" + project_region = "us-east-1" + + [acme] + enabled = false + + [clickhouse] + url = "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" + admin_url = "clickhouse://unkey_user_admin:C57RqT5EPZBqCJkMxN9mEZZEzMPcw9yBlwhIizk99t7kx6uLi9rYmtWObsXzdl@clickhouse:9000?secure=false&skip_verify=true" + + [github] + app_id = 0 + private_key_pem = "${UNKEY_GITHUB_PRIVATE_KEY_PEM}" + --- apiVersion: apps/v1 kind: Deployment @@ -22,10 +76,13 @@ spec: hostPath: path: /var/run/docker.sock type: Socket + - name: config + configMap: + name: ctrl-worker-config containers: - name: ctrl-worker image: unkey/go:latest - args: ["run", "ctrl", "worker"] + args: ["run", "ctrl", "worker", "--config", "/etc/unkey/unkey.toml"] imagePullPolicy: Never # Use local images ports: - containerPort: 9080 @@ -42,95 +99,26 @@ spec: initialDelaySeconds: 5 periodSeconds: 5 env: - # Server Configuration - - name: UNKEY_INSTANCE_ID - value: "worker-dev" - # Database Configuration - - name: UNKEY_DATABASE_PRIMARY - value: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" - - # Vault Configuration (required) - - name: UNKEY_VAULT_URL - value: http://vault:8060 - name: UNKEY_VAULT_TOKEN value: vault-test-token-123 - - # Build Configuration - - name: UNKEY_BUILD_PLATFORM - value: "linux/arm64" - - # Registry Configuration - - name: UNKEY_REGISTRY_URL - value: "registry.depot.dev" - - name: UNKEY_REGISTRY_USERNAME - value: "x-token" - name: UNKEY_REGISTRY_PASSWORD valueFrom: secretKeyRef: name: depot-credentials key: UNKEY_DEPOT_TOKEN optional: true - - # Depot-Specific Configuration - - name: UNKEY_DEPOT_API_URL - value: "https://api.depot.dev" - - name: UNKEY_DEPOT_PROJECT_REGION - value: "us-east-1" - - # ACME Configuration - - name: UNKEY_ACME_ENABLED - value: "false" - - # Domain configuration (used by deploy and routing services) - - name: UNKEY_DEFAULT_DOMAIN - value: "unkey.local" - - name: UNKEY_CNAME_DOMAIN - value: "unkey.local" - - # Restate Configuration - - name: UNKEY_RESTATE_ADMIN_URL - value: "http://restate:9070" - - name: UNKEY_RESTATE_HTTP_PORT - value: "9080" - - name: UNKEY_RESTATE_REGISTER_AS - value: "http://ctrl-worker:9080" - - # ClickHouse Configuration - - name: UNKEY_CLICKHOUSE_URL - value: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" - - name: UNKEY_CLICKHOUSE_ADMIN_URL - value: "clickhouse://unkey_user_admin:C57RqT5EPZBqCJkMxN9mEZZEzMPcw9yBlwhIizk99t7kx6uLi9rYmtWObsXzdl@clickhouse:9000?secure=false&skip_verify=true" - - # GitHub App Configuration (optional) - - name: UNKEY_GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: github-credentials - key: UNKEY_GITHUB_APP_ID - optional: true - name: UNKEY_GITHUB_PRIVATE_KEY_PEM valueFrom: secretKeyRef: name: github-credentials key: UNKEY_GITHUB_PRIVATE_KEY_PEM optional: true - - name: UNKEY_ALLOW_UNAUTHENTICATED_DEPLOYMENTS - valueFrom: - secretKeyRef: - name: github-credentials - key: UNKEY_ALLOW_UNAUTHENTICATED_DEPLOYMENTS - optional: true - - - name: UNKEY_SENTINEL_IMAGE - value: "unkey/sentinel:latest" - envFrom: - # Optional webhooks (heartbeat URLs, Slack webhooks) - - configMapRef: - name: ctrl-worker-webhooks - optional: true volumeMounts: - name: docker-socket mountPath: /var/run/docker.sock + - name: config + mountPath: /etc/unkey + readOnly: true initContainers: - name: wait-for-dependencies image: busybox:1.36 @@ -153,10 +141,6 @@ spec: selector: app: ctrl-worker ports: - - name: health - port: 7092 - targetPort: 7092 - protocol: TCP - name: restate port: 9080 targetPort: 9080 diff --git a/dev/k8s/manifests/frontline.yaml b/dev/k8s/manifests/frontline.yaml index c8f27b8ccb..796ab47626 100644 --- a/dev/k8s/manifests/frontline.yaml +++ b/dev/k8s/manifests/frontline.yaml @@ -1,4 +1,32 @@ --- +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontline-config + namespace: unkey +data: + unkey.toml: | + region = "local.dev" + http_port = 7070 + https_port = 7443 + apex_domain = "unkey.local" + ctrl_addr = "http://ctrl-api:7091" + + [tls] + enabled = true + cert_file = "/certs/unkey.local.crt" + key_file = "/certs/unkey.local.key" + + [database] + primary = "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + + [otel] + enabled = false + + [vault] + url = "http://vault:8060" + token = "vault-test-token-123" +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -19,39 +47,17 @@ spec: containers: - name: frontline image: unkey/go:latest - args: ["run", "frontline"] + args: ["run", "frontline", "--config", "/etc/unkey/unkey.toml"] imagePullPolicy: Never ports: - containerPort: 7070 name: http - containerPort: 7443 name: https - env: - - name: UNKEY_HTTP_PORT - value: "7070" - - name: UNKEY_HTTPS_PORT - value: "7443" - - name: UNKEY_TLS_ENABLED - value: "true" - - name: UNKEY_TLS_CERT_FILE - value: "/certs/unkey.local.crt" - - name: UNKEY_TLS_KEY_FILE - value: "/certs/unkey.local.key" - - name: UNKEY_REGION - value: "local.dev" - - name: UNKEY_APEX_DOMAIN - value: "unkey.local" - - name: UNKEY_DATABASE_PRIMARY - value: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" - - name: UNKEY_CTRL_ADDR - value: "http://ctrl-api:7091" - - name: UNKEY_VAULT_URL - value: "http://vault:8060" - - name: UNKEY_VAULT_TOKEN - value: "vault-test-token-123" - - name: UNKEY_OTEL - value: "false" volumeMounts: + - name: config + mountPath: /etc/unkey + readOnly: true - name: tls-certs mountPath: /certs readOnly: true @@ -68,6 +74,9 @@ spec: initialDelaySeconds: 10 periodSeconds: 10 volumes: + - name: config + configMap: + name: frontline-config - name: tls-certs configMap: name: frontline-certs diff --git a/dev/k8s/manifests/krane.yaml b/dev/k8s/manifests/krane.yaml index c22114e78c..3e08325ed0 100644 --- a/dev/k8s/manifests/krane.yaml +++ b/dev/k8s/manifests/krane.yaml @@ -1,4 +1,24 @@ apiVersion: v1 +kind: ConfigMap +metadata: + name: krane-config + namespace: unkey + labels: + app: krane + component: krane +data: + unkey.toml: | + region = "local.dev" + + [control_plane] + url = "http://ctrl-api:7091" + bearer = "your-local-dev-key" + + [vault] + url = "http://vault:8060" + token = "vault-test-token-123" +--- +apiVersion: v1 kind: Service metadata: name: krane @@ -37,7 +57,7 @@ spec: containers: - name: krane image: unkey/go:latest - args: ["run", "krane"] + args: ["run", "krane", "--config", "/etc/unkey/unkey.toml"] imagePullPolicy: Never # Use local images ports: - containerPort: 8070 @@ -53,16 +73,11 @@ spec: port: 8070 initialDelaySeconds: 10 periodSeconds: 10 - env: - # Server configuration - - name: UNKEY_REGION - value: "local.dev" - - name: "UNKEY_CONTROL_PLANE_URL" - value: "http://ctrl-api:7091" - - name: "UNKEY_CONTROL_PLANE_BEARER" - value: "your-local-dev-key" - # Vault configuration for SecretsService - - name: UNKEY_VAULT_URL - value: "http://vault:8060" - - name: UNKEY_VAULT_TOKEN - value: "vault-test-token-123" + volumeMounts: + - name: config + mountPath: /etc/unkey + readOnly: true + volumes: + - name: config + configMap: + name: krane-config diff --git a/dev/k8s/manifests/preflight.yaml b/dev/k8s/manifests/preflight.yaml index 64bdc18a66..9ea7922242 100644 --- a/dev/k8s/manifests/preflight.yaml +++ b/dev/k8s/manifests/preflight.yaml @@ -39,6 +39,30 @@ subjects: name: preflight namespace: unkey --- +# ConfigMap for the preflight +apiVersion: v1 +kind: ConfigMap +metadata: + name: preflight-config + namespace: unkey +data: + unkey.toml: | + http_port = 8443 + krane_endpoint = "http://krane.unkey.svc.cluster.local:8070" + depot_token = "${UNKEY_DEPOT_TOKEN}" + + [tls] + cert_file = "/certs/tls.crt" + key_file = "/certs/tls.key" + + [inject] + image = "inject:latest" + image_pull_policy = "Never" + + [registry] + insecure_registries = ["ctlptl-registry.unkey.svc.cluster.local:5000"] + aliases = ["ctlptl-registry:5000=ctlptl-registry.unkey.svc.cluster.local:5000"] +--- # Deployment for the preflight apiVersion: apps/v1 kind: Deployment @@ -89,25 +113,9 @@ spec: containers: - name: webhook image: unkey/go:latest - args: ["run", "preflight"] + args: ["run", "preflight", "--config", "/etc/unkey/unkey.toml"] imagePullPolicy: IfNotPresent env: - - name: UNKEY_HTTP_PORT - value: "8443" - - name: UNKEY_TLS_CERT_FILE - value: "/certs/tls.crt" - - name: UNKEY_TLS_KEY_FILE - value: "/certs/tls.key" - - name: UNKEY_INJECT_IMAGE - value: "inject:latest" - - name: UNKEY_INJECT_IMAGE_PULL_POLICY - value: "Never" # Local dev uses pre-loaded images; use IfNotPresent in prod - - name: UNKEY_KRANE_ENDPOINT - value: "http://krane.unkey.svc.cluster.local:8070" - - name: UNKEY_INSECURE_REGISTRIES - value: "ctlptl-registry.unkey.svc.cluster.local:5000" - - name: UNKEY_REGISTRY_ALIASES - value: "ctlptl-registry:5000=ctlptl-registry.unkey.svc.cluster.local:5000" - name: UNKEY_DEPOT_TOKEN valueFrom: secretKeyRef: @@ -121,6 +129,9 @@ spec: - name: tls-certs mountPath: /certs readOnly: true + - name: config + mountPath: /etc/unkey + readOnly: true resources: requests: cpu: 50m @@ -172,6 +183,9 @@ spec: volumes: - name: tls-certs emptyDir: {} + - name: config + configMap: + name: preflight-config --- # Service for the webhook apiVersion: v1 diff --git a/dev/k8s/manifests/vault.yaml b/dev/k8s/manifests/vault.yaml index ef15540a91..2f77a5d4c6 100644 --- a/dev/k8s/manifests/vault.yaml +++ b/dev/k8s/manifests/vault.yaml @@ -1,3 +1,25 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-config + namespace: unkey +data: + unkey.toml: | + instance_id = "vault-dev" + http_port = 8060 + bearer_token = "vault-test-token-123" + master_keys = ["Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U="] + + [s3] + url = "http://s3:3902" + bucket = "vault" + access_key_id = "minio_root_user" + access_key_secret = "minio_root_password" + + [otel] + enabled = false +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -20,26 +42,15 @@ spec: containers: - name: vault image: unkey/go:latest - args: ["run", "vault"] + args: ["run", "vault", "--config", "/etc/unkey/unkey.toml"] ports: - name: http containerPort: 8060 protocol: TCP - env: - - name: UNKEY_HTTP_PORT - value: "8060" - - name: UNKEY_S3_URL - value: "http://s3:3902" - - name: UNKEY_S3_BUCKET - value: "vault" - - name: UNKEY_S3_ACCESS_KEY_ID - value: "minio_root_user" - - name: UNKEY_S3_ACCESS_KEY_SECRET - value: "minio_root_password" - - name: UNKEY_MASTER_KEYS - value: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" - - name: UNKEY_BEARER_TOKEN - value: "vault-test-token-123" + volumeMounts: + - name: config + mountPath: /etc/unkey + readOnly: true livenessProbe: exec: command: @@ -63,6 +74,10 @@ spec: limits: memory: "256Mi" cpu: "200m" + volumes: + - name: config + configMap: + name: vault-config --- apiVersion: v1 kind: Service diff --git a/svc/api/BUILD.bazel b/svc/api/BUILD.bazel index 74dcc85768..eff3e8a67c 100644 --- a/svc/api/BUILD.bazel +++ b/svc/api/BUILD.bazel @@ -20,6 +20,7 @@ go_library( "//internal/services/usagelimiter", "//pkg/clickhouse", "//pkg/clock", + "//pkg/config", "//pkg/counter", "//pkg/db", "//pkg/eventstream", @@ -45,6 +46,7 @@ go_test( srcs = ["cancel_test.go"], deps = [ ":api", + "//pkg/config", "//pkg/dockertest", "//pkg/uid", "@com_github_stretchr_testify//require", diff --git a/svc/api/cancel_test.go b/svc/api/cancel_test.go index 9c70c43e5e..9ac534b0ca 100644 --- a/svc/api/cancel_test.go +++ b/svc/api/cancel_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/stretchr/testify/require" + sharedconfig "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/dockertest" "github.com/unkeyed/unkey/pkg/uid" "github.com/unkeyed/unkey/svc/api" @@ -31,17 +32,19 @@ func TestContextCancellation(t *testing.T) { // Configure the API server config := api.Config{ - Platform: "test", - Image: "test", - Listener: ln, - Region: "test-region", - Clock: nil, // Will use real clock - InstanceID: uid.New(uid.InstancePrefix), - RedisUrl: redisUrl, - ClickhouseURL: "", - DatabasePrimary: dbDsn, - DatabaseReadonlyReplica: "", - OtelEnabled: false, + Platform: "test", + Image: "test", + Listener: ln, + Region: "test-region", + Clock: nil, // Will use real clock + InstanceID: uid.New(uid.InstancePrefix), + RedisURL: redisUrl, + Database: sharedconfig.DatabaseConfig{ + Primary: dbDsn, + }, + Otel: sharedconfig.OtelConfig{ + Enabled: false, + }, } // Create a channel to receive the result of the Run function diff --git a/svc/api/config.go b/svc/api/config.go index 1bae2ac831..87495dd850 100644 --- a/svc/api/config.go +++ b/svc/api/config.go @@ -1,133 +1,194 @@ package api import ( + "fmt" "net" - "time" "github.com/unkeyed/unkey/pkg/clock" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/tls" ) const ( - // DefaultCacheInvalidationTopic is the default Kafka topic name for cache invalidation events + // DefaultCacheInvalidationTopic is the fallback Kafka topic name used when + // [KafkaConfig.CacheInvalidationTopic] is empty. This exists for programmatic + // callers (tests, harnesses) that construct [Config] directly without going + // through [config.Load], which would apply the struct tag default. DefaultCacheInvalidationTopic = "cache-invalidations" ) -type Config struct { - // InstanceID is the unique identifier for this instance of the API server - InstanceID string - - // Platform identifies the cloud platform where the node is running (e.g., aws, gcp, hetzner) - Platform string - - // Image specifies the container image identifier including repository and tag - Image string - - // HttpPort defines the HTTP port for the API server to listen on (default: 7070) - // Used in production deployments. Ignored if Listener is provided. - HttpPort int - - // Listener defines a pre-created network listener for the HTTP server - // If provided, the server will use this listener instead of creating one from HttpPort - // This is intended for testing scenarios where ephemeral ports are needed to avoid conflicts - Listener net.Listener - - // Region identifies the geographic region where this node is deployed - Region string - - // RedisUrl is the Redis database connection string - RedisUrl string - - // Enable TestMode - TestMode bool - - // --- ClickHouse configuration --- - - // ClickhouseURL is the ClickHouse database connection string - ClickhouseURL string - - // ClickhouseAnalyticsURL is the base URL for workspace-specific analytics connections - // Workspace credentials are injected programmatically at connection time - // Examples: "http://clickhouse:8123/default", "clickhouse://clickhouse:9000/default" - ClickhouseAnalyticsURL string - - // --- Database configuration --- - - // DatabasePrimary is the primary database connection string for read and write operations - DatabasePrimary string - - // DatabaseReadonlyReplica is an optional read-replica database connection string for read operations - DatabaseReadonlyReplica string - - // --- OpenTelemetry configuration --- - - // Enable sending otel data to the collector endpoint for metrics, traces, and logs - OtelEnabled bool - OtelTraceSamplingRate float64 - - PrometheusPort int - Clock clock.Clock - - // --- TLS configuration --- - - // TLSConfig provides HTTPS support when set - TLSConfig *tls.Config - - // Vault Configuration - VaultURL string - VaultToken string - - // --- Kafka configuration --- - - // KafkaBrokers is the list of Kafka broker addresses - KafkaBrokers []string - - // CacheInvalidationTopic is the Kafka topic name for cache invalidation events - // If empty, defaults to DefaultCacheInvalidationTopic - CacheInvalidationTopic string - - // --- ClickHouse proxy configuration --- - - // ChproxyToken is the authentication token for ClickHouse proxy endpoints - ChproxyToken string - - // --- CTRL service configuration --- - - // CtrlURL is the CTRL service connection URL - CtrlURL string - - // CtrlToken is the Bearer token for CTRL service authentication - CtrlToken string - - // --- pprof configuration --- - - // PprofEnabled controls whether the pprof profiling endpoints are available - PprofEnabled bool - - // PprofUsername is the username for pprof Basic Auth - // If empty along with PprofPassword, pprof endpoints will be accessible without authentication - PprofUsername string +// ClickHouseConfig configures connections to ClickHouse for analytics storage. +// All fields are optional; when URL is empty, a no-op analytics backend is used. +type ClickHouseConfig struct { + // URL is the ClickHouse connection string for the shared analytics cluster. + // When empty, analytics writes are silently discarded. + // Example: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" + URL string `toml:"url"` + + // AnalyticsURL is the base URL for workspace-specific analytics connections. + // Unlike URL, this endpoint receives per-workspace credentials injected at + // connection time by the analytics service. Only used when both this field + // and a [VaultConfig] are configured. + // Example: "http://clickhouse:8123/default" + AnalyticsURL string `toml:"analytics_url"` + + // ProxyToken is the bearer token for authenticating against ClickHouse proxy + // endpoints exposed by the API server itself. + ProxyToken string `toml:"proxy_token"` +} - // PprofPassword is the password for pprof Basic Auth - // If empty along with PprofUsername, pprof endpoints will be accessible without authentication - PprofPassword string +// KafkaConfig configures the Kafka connection used for distributed cache +// invalidation across API server instances. When Brokers is empty, cache +// invalidation is local-only and a no-op topic is used. +type KafkaConfig struct { + // Brokers is the list of Kafka broker addresses. When empty, distributed + // cache invalidation is disabled and each node operates independently. + // Example: ["kafka-0:9092", "kafka-1:9092"] + Brokers []string `toml:"brokers" config:"required,nonempty"` + + // CacheInvalidationTopic is the Kafka topic name for broadcasting cache + // invalidation events between API nodes. + CacheInvalidationTopic string `toml:"cache_invalidation_topic" config:"default=cache-invalidations"` +} - // MaxRequestBodySize sets the maximum allowed request body size in bytes. - // If 0 or negative, no limit is enforced. Default is 0 (no limit). - // This helps prevent DoS attacks from excessively large request bodies. - MaxRequestBodySize int64 +// CtrlConfig configures the connection to the CTRL service, which manages +// deployments and rolling updates across the cluster. +type CtrlConfig struct { + // URL is the CTRL service endpoint. + // Example: "http://ctrl-api:7091" + URL string `toml:"url"` - // --- Logging sampler configuration --- + // Token is the bearer token used to authenticate with the CTRL service. + Token string `toml:"token"` +} - // LogSampleRate is the baseline probability (0.0-1.0) of emitting log events. - LogSampleRate float64 +// PprofConfig controls the Go pprof profiling endpoints served at /debug/pprof/*. +// Pprof is enabled when this section is present in the config file and disabled +// when omitted (the field is a pointer on [Config]). +type PprofConfig struct { + // Username is the Basic Auth username for pprof endpoints. When both + // Username and Password are empty, pprof endpoints are served without + // authentication — only appropriate in development environments. + Username string `toml:"username"` + + // Password is the Basic Auth password for pprof endpoints. + Password string `toml:"password"` +} - // LogSlowThreshold defines what duration qualifies as "slow" for sampling. - LogSlowThreshold time.Duration +// Config holds the complete configuration for the API server. It is designed to +// be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[api.Config]("/etc/unkey/api.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]. +// +// Three fields — Listener, Clock, and TLSConfig — are runtime-only and cannot +// be set through a config file. They are tagged toml:"-" and must be set +// programmatically after loading. +type Config struct { + // InstanceID identifies this particular API server instance. Used in log + // attribution, Kafka consumer group membership, and cache invalidation + // messages so that a node can ignore its own broadcasts. + InstanceID string `toml:"instance_id"` + + // Platform identifies the cloud platform where this node runs + // (e.g. "aws", "gcp", "hetzner", "kubernetes"). Appears in structured + // logs and metrics labels for filtering by infrastructure. + Platform string `toml:"platform"` + + // Image is the container image identifier (e.g. "unkey/api:v1.2.3"). + // Logged at startup for correlating deployments with behavior changes. + Image string `toml:"image"` + + // HttpPort is the TCP port the API server binds to. Ignored when Listener + // is set, which is the case in test harnesses that use ephemeral ports. + HttpPort int `toml:"http_port" config:"default=7070,min=1,max=65535"` + + // Region is the geographic region identifier (e.g. "us-east-1", "eu-west-1"). + // Included in structured logs and used by the key service when recording + // which region served a verification request. + Region string `toml:"region" config:"default=unknown"` + + // RedisURL is the connection string for the Redis instance backing + // distributed rate limiting counters and usage tracking. + // Example: "redis://redis:6379" + RedisURL string `toml:"redis_url" config:"required,nonempty"` + + // TestMode relaxes certain security checks and trusts client-supplied + // headers that would normally be rejected. This exists for integration + // tests that need to inject specific request metadata. + // Do not enable in production. + TestMode bool `toml:"test_mode" config:"default=false"` + + // 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"` + + // MaxRequestBodySize caps incoming request bodies at this many bytes. + // The zen server rejects requests exceeding this limit with a 413 status. + // Set to 0 or negative to disable the limit. Defaults to 10 MiB. + MaxRequestBodySize int64 `toml:"max_request_body_size" config:"default=10485760"` + + // Database configures MySQL connections. See [config.DatabaseConfig]. + Database config.DatabaseConfig `toml:"database"` + + // ClickHouse configures analytics storage. See [ClickHouseConfig]. + ClickHouse ClickHouseConfig `toml:"clickhouse"` + + // Otel configures OpenTelemetry export. See [config.OtelConfig]. + Otel config.OtelConfig `toml:"otel"` + + // TLS provides filesystem paths for HTTPS certificate and key. + // See [config.TLSFiles]. + TLS config.TLSFiles `toml:"tls"` + + // Vault configures the encryption/decryption service. See [config.VaultConfig]. + Vault config.VaultConfig `toml:"vault"` + + // Kafka configures distributed cache invalidation. See [KafkaConfig]. + // When nil (section omitted), cache invalidation is local-only. + Kafka *KafkaConfig `toml:"kafka"` + + // Ctrl configures the deployment management service. See [CtrlConfig]. + Ctrl CtrlConfig `toml:"ctrl"` + + // Pprof configures Go profiling endpoints. See [PprofConfig]. + // When nil (section omitted), pprof endpoints are not registered. + Pprof *PprofConfig `toml:"pprof"` + + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` + + // Listener is a pre-created [net.Listener] for the HTTP server. When set, + // the server uses this listener instead of binding to HttpPort. This is + // intended for tests that need ephemeral ports to avoid conflicts. + Listener net.Listener `toml:"-"` + + // Clock provides time operations and is injected for testability. Production + // callers set this to [clock.New]; tests can substitute a fake clock to + // control time progression. + Clock clock.Clock `toml:"-"` + + // TLSConfig is the resolved [tls.Config] built from [TLSFiles.CertFile] + // and [TLSFiles.KeyFile] at startup. This field is populated by the CLI + // entrypoint after loading the config file and must not be set in TOML. + TLSConfig *tls.Config `toml:"-"` } -func (c Config) Validate() error { - // TLS configuration is validated when it's created from files - // Other validations may be added here in the future +// 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. +// +// Currently validates that TLS certificate and key paths are either both +// provided or both absent — setting only one is an error. +func (c *Config) Validate() error { + certFile := c.TLS.CertFile + keyFile := c.TLS.KeyFile + if (certFile == "") != (keyFile == "") { + return fmt.Errorf("both tls.cert_file and tls.key_file must be provided to enable HTTPS") + } return nil } diff --git a/svc/api/integration/BUILD.bazel b/svc/api/integration/BUILD.bazel index 99fc062079..26dc0d5a06 100644 --- a/svc/api/integration/BUILD.bazel +++ b/svc/api/integration/BUILD.bazel @@ -11,6 +11,7 @@ go_library( deps = [ "//pkg/clickhouse", "//pkg/clock", + "//pkg/config", "//pkg/db", "//pkg/dockertest", "//pkg/testutil/containers", diff --git a/svc/api/integration/harness.go b/svc/api/integration/harness.go index 0e8eac4c6b..53f2749062 100644 --- a/svc/api/integration/harness.go +++ b/svc/api/integration/harness.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/pkg/clickhouse" "github.com/unkeyed/unkey/pkg/clock" + sharedconfig "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/db" "github.com/unkeyed/unkey/pkg/dockertest" "github.com/unkeyed/unkey/pkg/testutil/containers" @@ -139,36 +140,55 @@ func (h *Harness) RunAPI(config ApiConfig) *ApiCluster { kafkaBrokers := containers.Kafka(h.t) vaultURL, vaultToken := containers.Vault(h.t) apiConfig := api.Config{ - CacheInvalidationTopic: "", - MaxRequestBodySize: 0, - HttpPort: 7070, - ChproxyToken: "", - Platform: "test", - Image: "test", - Listener: ln, - DatabasePrimary: mysqlHostCfg.FormatDSN(), - DatabaseReadonlyReplica: "", - ClickhouseURL: clickhouseHostDSN, - ClickhouseAnalyticsURL: "", - RedisUrl: h.redisUrl, - Region: "test", - InstanceID: fmt.Sprintf("test-node-%d", i), - Clock: clock.New(), - TestMode: true, - OtelEnabled: false, - OtelTraceSamplingRate: 0.0, - PrometheusPort: 0, - TLSConfig: nil, - VaultURL: vaultURL, - VaultToken: vaultToken, - KafkaBrokers: kafkaBrokers, // Use host brokers for test runner connections - PprofEnabled: true, - PprofUsername: "unkey", - PprofPassword: "password", - CtrlURL: "http://ctrl:7091", - CtrlToken: "your-local-dev-key", - LogSampleRate: 1.0, - LogSlowThreshold: time.Second, + HttpPort: 7070, + Platform: "test", + Image: "test", + Listener: ln, + RedisURL: h.redisUrl, + Region: "test", + InstanceID: fmt.Sprintf("test-node-%d", i), + Clock: clock.New(), + TestMode: true, + PrometheusPort: 0, + TLSConfig: nil, + MaxRequestBodySize: 0, + Database: sharedconfig.DatabaseConfig{ + Primary: mysqlHostCfg.FormatDSN(), + ReadonlyReplica: "", + }, + ClickHouse: api.ClickHouseConfig{ + URL: clickhouseHostDSN, + AnalyticsURL: "", + ProxyToken: "", + }, + Otel: sharedconfig.OtelConfig{ + Enabled: false, + TraceSamplingRate: 0.0, + }, + TLS: sharedconfig.TLSFiles{ + CertFile: "", + KeyFile: "", + }, + Vault: sharedconfig.VaultConfig{ + URL: vaultURL, + Token: vaultToken, + }, + Kafka: &api.KafkaConfig{ + Brokers: kafkaBrokers, // Use host brokers for test runner connections + CacheInvalidationTopic: "", + }, + Ctrl: api.CtrlConfig{ + URL: "http://ctrl:7091", + Token: "your-local-dev-key", + }, + Pprof: &api.PprofConfig{ + Username: "unkey", + Password: "password", + }, + Logging: sharedconfig.LoggingConfig{ + SampleRate: 1.0, + SlowThreshold: time.Second, + }, } // Start API server in goroutine diff --git a/svc/api/run.go b/svc/api/run.go index 6e1499f916..aa26443e14 100644 --- a/svc/api/run.go +++ b/svc/api/run.go @@ -45,8 +45,8 @@ 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, }) logger.AddBaseAttrs(slog.GroupAttrs("instance", slog.String("id", cfg.InstanceID), @@ -66,13 +66,13 @@ func Run(ctx context.Context, cfg Config) error { // This is a little ugly, but the best we can do to resolve the circular dependency until we rework the logger. var shutdownGrafana func(context.Context) error - if cfg.OtelEnabled { + if cfg.Otel.Enabled { shutdownGrafana, err = otel.InitGrafana(ctx, otel.Config{ Application: "api", Version: version.Version, InstanceID: cfg.InstanceID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -85,8 +85,8 @@ func Run(ctx context.Context, cfg Config) error { r.DeferCtx(shutdownGrafana) db, 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) @@ -116,9 +116,9 @@ func Run(ctx context.Context, cfg Config) error { } 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) @@ -149,7 +149,7 @@ func Run(ctx context.Context, cfg Config) error { } ctr, err := counter.NewRedis(counter.RedisConfig{ - RedisURL: cfg.RedisUrl, + RedisURL: cfg.RedisURL, }) if err != nil { return fmt.Errorf("unable to create counter: %w", err) @@ -176,12 +176,12 @@ func Run(ctx context.Context, cfg Config) error { } var vaultClient vault.Client - if cfg.VaultURL != "" { + if cfg.Vault.URL != "" { connectClient := vaultv1connect.NewVaultServiceClient( &http.Client{}, - cfg.VaultURL, + cfg.Vault.URL, connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", cfg.VaultToken), + "Authorization": fmt.Sprintf("Bearer %s", cfg.Vault.Token), })), ) vaultClient = vault.NewConnectClient(connectClient) @@ -196,16 +196,16 @@ func Run(ctx context.Context, cfg Config) error { // Initialize cache invalidation topic cacheInvalidationTopic := eventstream.NewNoopTopic[*cachev1.CacheInvalidationEvent]() - if len(cfg.KafkaBrokers) > 0 { - logger.Info("Initializing cache invalidation topic", "brokers", cfg.KafkaBrokers, "instanceID", cfg.InstanceID) + if cfg.Kafka != nil { + logger.Info("Initializing cache invalidation topic", "brokers", cfg.Kafka.Brokers, "instanceID", cfg.InstanceID) - topicName := cfg.CacheInvalidationTopic + topicName := cfg.Kafka.CacheInvalidationTopic if topicName == "" { topicName = DefaultCacheInvalidationTopic } cacheInvalidationTopic, err = eventstream.NewTopic[*cachev1.CacheInvalidationEvent](eventstream.TopicConfig{ - Brokers: cfg.KafkaBrokers, + Brokers: cfg.Kafka.Brokers, Topic: topicName, InstanceID: cfg.InstanceID, }) @@ -244,12 +244,12 @@ func Run(ctx context.Context, cfg Config) error { // Initialize analytics connection manager analyticsConnMgr := analytics.NewNoopConnectionManager() - if cfg.ClickhouseAnalyticsURL != "" && vaultClient != nil { + if cfg.ClickHouse.AnalyticsURL != "" && vaultClient != nil { analyticsConnMgr, err = analytics.NewConnectionManager(analytics.ConnectionManagerConfig{ SettingsCache: caches.ClickhouseSetting, Database: db, Clock: clk, - BaseURL: cfg.ClickhouseAnalyticsURL, + BaseURL: cfg.ClickHouse.AnalyticsURL, Vault: vaultClient, }) if err != nil { @@ -260,13 +260,19 @@ func Run(ctx context.Context, cfg Config) error { // Initialize CTRL deployment client using bufconnect ctrlDeploymentClient := ctrlv1connect.NewDeployServiceClient( &http.Client{}, - cfg.CtrlURL, + cfg.Ctrl.URL, connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", cfg.CtrlToken), + "Authorization": fmt.Sprintf("Bearer %s", cfg.Ctrl.Token), })), ) - logger.Info("CTRL clients initialized", "url", cfg.CtrlURL) + logger.Info("CTRL clients initialized", "url", cfg.Ctrl.URL) + + var pprofUsername, pprofPassword string + if cfg.Pprof != nil { + pprofUsername = cfg.Pprof.Username + pprofPassword = cfg.Pprof.Password + } routes.Register(srv, &routes.Services{ Database: db, @@ -277,11 +283,11 @@ func Run(ctx context.Context, cfg Config) error { Auditlogs: auditlogSvc, Caches: caches, Vault: vaultClient, - ChproxyToken: cfg.ChproxyToken, + ChproxyToken: cfg.ClickHouse.ProxyToken, CtrlDeploymentClient: ctrlDeploymentClient, - PprofEnabled: cfg.PprofEnabled, - PprofUsername: cfg.PprofUsername, - PprofPassword: cfg.PprofPassword, + PprofEnabled: cfg.Pprof != nil, + PprofUsername: pprofUsername, + PprofPassword: pprofPassword, UsageLimiter: ulSvc, AnalyticsConnectionManager: analyticsConnMgr, }, diff --git a/svc/ctrl/api/BUILD.bazel b/svc/ctrl/api/BUILD.bazel index 748926ef1f..0bbb5c4b75 100644 --- a/svc/ctrl/api/BUILD.bazel +++ b/svc/ctrl/api/BUILD.bazel @@ -18,6 +18,7 @@ go_library( "//gen/proto/hydra/v1:hydra", "//pkg/cache", "//pkg/clock", + "//pkg/config", "//pkg/db", "//pkg/logger", "//pkg/otel", @@ -54,6 +55,7 @@ go_test( "//gen/proto/ctrl/v1:ctrl", "//gen/proto/ctrl/v1/ctrlv1connect", "//gen/proto/hydra/v1:hydra", + "//pkg/config", "//pkg/db", "//pkg/dockertest", "//pkg/logger", diff --git a/svc/ctrl/api/config.go b/svc/ctrl/api/config.go index bcec58b0f4..3c6eb75e85 100644 --- a/svc/ctrl/api/config.go +++ b/svc/ctrl/api/config.go @@ -1,6 +1,9 @@ package api import ( + "fmt" + + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/tls" ) @@ -11,91 +14,113 @@ import ( type RestateConfig struct { // URL is the Restate ingress endpoint URL for workflow invocation. // Used by clients to start and interact with workflow executions. - // Example: "http://restate:8080". - URL string + URL string `toml:"url" config:"default=http://restate:8080"` // AdminURL is the Restate admin API endpoint for managing invocations. - // Used for canceling invocations. Example: "http://restate:9070". - AdminURL string + // Used for canceling invocations. + AdminURL string `toml:"admin_url" config:"default=http://restate:9070"` // APIKey is the authentication key for Restate ingress requests. // If set, this key will be sent with all requests to the Restate ingress. - APIKey string + APIKey string `toml:"api_key"` +} + +// GitHubConfig holds GitHub App integration settings for webhook-triggered +// deployments. +type GitHubConfig struct { + // WebhookSecret is the secret used to verify webhook signatures. + // Configured in the GitHub App webhook settings. + WebhookSecret string `toml:"webhook_secret"` } -// Config holds configuration for the control plane API server. +// Config holds the complete configuration for the control plane API server. +// It is designed to be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[api.Config]("/etc/unkey/ctrl-api.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]. // -// The API server handles Connect RPC requests and delegates workflow -// execution to Restate. It does NOT run workflows directly - that's -// the worker's job. +// TLSConfig is runtime-only and cannot be set through a config file. It is +// tagged toml:"-" and must be set programmatically after loading. type Config struct { // InstanceID is the unique identifier for this control plane instance. // Used for logging, tracing, and cluster coordination. - InstanceID string + InstanceID string `toml:"instance_id"` // Region is the geographic region where this control plane instance runs. // Used for logging, tracing, and region-aware routing decisions. - Region string + Region string `toml:"region" config:"required,nonempty"` // HttpPort defines the HTTP port for the control plane server. - // Default: 8080. Cannot be 0. - HttpPort int + // Default: 7091. Cannot be 0. + HttpPort int `toml:"http_port" config:"default=7091,min=1,max=65535"` // PrometheusPort specifies the port for exposing Prometheus metrics. // Set to 0 to disable metrics exposure. When enabled, metrics are served // on all interfaces (0.0.0.0) on the specified port. - PrometheusPort int - - // DatabasePrimary is the primary database connection string. - // Used for both read and write operations to persistent storage. - DatabasePrimary string - - // OtelEnabled enables sending telemetry data to collector endpoint. - // When true, enables metrics, traces, and structured logs. - OtelEnabled bool - - // OtelTraceSamplingRate controls the percentage of traces sampled. - // Range: 0.0 (no traces) to 1.0 (all traces). Recommended: 0.1. - OtelTraceSamplingRate float64 - - // TLSConfig contains TLS configuration for HTTPS server. - // When nil, server runs in HTTP mode for development. - TLSConfig *tls.Config + PrometheusPort int `toml:"prometheus_port"` // AuthToken is the authentication token for control plane API access. // Used by clients and services to authenticate with this control plane. - AuthToken string - - // Restate configures workflow engine integration. - // The API invokes workflows via Restate ingress. - Restate RestateConfig + AuthToken string `toml:"auth_token" config:"required,nonempty"` // AvailableRegions is a list of available regions for deployments. // Typically in the format "region.provider", ie "us-east-1.aws", "local.dev" - AvailableRegions []string - - // GitHubWebhookSecret is the secret used to verify webhook signatures. - // Configured in the GitHub App webhook settings. - GitHubWebhookSecret string + AvailableRegions []string `toml:"available_regions"` // DefaultDomain is the fallback domain for system operations. // Used for wildcard certificate bootstrapping. When set, the API will // ensure a wildcard certificate exists for *.{DefaultDomain}. - DefaultDomain string + DefaultDomain string `toml:"default_domain"` // RegionalDomain is the base domain for cross-region communication // between frontline instances. Combined with AvailableRegions to create // per-region wildcard certificates like *.{region}.{RegionalDomain}. - RegionalDomain string + RegionalDomain string `toml:"regional_domain"` // CnameDomain is the base domain for custom domain CNAME targets. // Each custom domain gets a unique subdomain like "{random}.{CnameDomain}". - // For production: "unkey-dns.com" - // For local: "unkey.local" - CnameDomain string + CnameDomain string `toml:"cname_domain"` + + // Database configures MySQL connections. See [config.DatabaseConfig]. + Database config.DatabaseConfig `toml:"database"` + + // Otel configures OpenTelemetry export. See [config.OtelConfig]. + Otel config.OtelConfig `toml:"otel"` + + // TLS provides filesystem paths for HTTPS certificate and key. + // See [config.TLSFiles]. + TLS config.TLSFiles `toml:"tls"` + + // Restate configures workflow engine integration. See [RestateConfig]. + Restate RestateConfig `toml:"restate"` + + // GitHub configures GitHub App webhook integration. See [GitHubConfig]. + GitHub GitHubConfig `toml:"github"` + + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` + + // TLSConfig is the resolved [tls.Config] built from [TLSFiles.CertFile] + // and [TLSFiles.KeyFile] at startup. This field is populated by the CLI + // entrypoint after loading the config file and must not be set in TOML. + TLSConfig *tls.Config `toml:"-"` } -// Validate checks the configuration for required fields and logical consistency. -func (c Config) Validate() error { +// 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. +// +// Currently validates that TLS certificate and key paths are either both +// provided or both absent — setting only one is an error. +func (c *Config) Validate() error { + certFile := c.TLS.CertFile + keyFile := c.TLS.KeyFile + if (certFile == "") != (keyFile == "") { + return fmt.Errorf("both tls.cert_file and tls.key_file must be provided to enable HTTPS") + } return nil } diff --git a/svc/ctrl/api/harness_test.go b/svc/ctrl/api/harness_test.go index 93b2af1376..1fd88e23bc 100644 --- a/svc/ctrl/api/harness_test.go +++ b/svc/ctrl/api/harness_test.go @@ -17,6 +17,7 @@ import ( restate "github.com/restatedev/sdk-go" restateServer "github.com/restatedev/sdk-go/server" "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/pkg/config" "github.com/unkeyed/unkey/pkg/db" "github.com/unkeyed/unkey/pkg/dockertest" "github.com/unkeyed/unkey/pkg/logger" @@ -92,23 +93,29 @@ func newWebhookHarness(t *testing.T, cfg webhookHarnessConfig) *webhookHarness { } apiConfig := Config{ - InstanceID: "test", - Region: "local", - HttpPort: ctrlPort, - PrometheusPort: 0, - DatabasePrimary: mysqlCfg.DSN, - OtelEnabled: false, - OtelTraceSamplingRate: 0, - TLSConfig: nil, - AuthToken: "", + InstanceID: "test", + Region: "local", + HttpPort: ctrlPort, + PrometheusPort: 0, + AuthToken: "", + AvailableRegions: []string{"local.dev"}, + DefaultDomain: "", + RegionalDomain: "", + Database: config.DatabaseConfig{ + Primary: mysqlCfg.DSN, + ReadonlyReplica: "", + }, + Otel: config.OtelConfig{ + Enabled: false, + TraceSamplingRate: 0, + }, Restate: RestateConfig{ URL: restateCfg.IngressURL, APIKey: "", }, - AvailableRegions: []string{"local.dev"}, - GitHubWebhookSecret: secret, - DefaultDomain: "", - RegionalDomain: "", + GitHub: GitHubConfig{ + WebhookSecret: secret, + }, } ctrlCtx, ctrlCancel := context.WithCancel(ctx) diff --git a/svc/ctrl/api/run.go b/svc/ctrl/api/run.go index 3af4bb75a3..8e97214930 100644 --- a/svc/ctrl/api/run.go +++ b/svc/ctrl/api/run.go @@ -55,13 +55,13 @@ func Run(ctx context.Context, cfg Config) error { // This is a little ugly, but the best we can do to resolve the circular dependency until we rework the logger. var shutdownGrafana func(context.Context) error - if cfg.OtelEnabled { + if cfg.Otel.Enabled { shutdownGrafana, err = otel.InitGrafana(ctx, otel.Config{ Application: "ctrl", Version: pkgversion.Version, InstanceID: cfg.InstanceID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -86,7 +86,7 @@ func Run(ctx context.Context, cfg Config) error { // Initialize database database, err := db.New(db.Config{ - PrimaryDSN: cfg.DatabasePrimary, + PrimaryDSN: cfg.Database.Primary, ReadOnlyDSN: "", }) if err != nil { @@ -163,11 +163,11 @@ func Run(ctx context.Context, cfg Config) error { CnameDomain: cfg.CnameDomain, }))) - if cfg.GitHubWebhookSecret != "" { + if cfg.GitHub.WebhookSecret != "" { mux.Handle("POST /webhooks/github", &GitHubWebhook{ db: database, restate: restateClient, - webhookSecret: cfg.GitHubWebhookSecret, + webhookSecret: cfg.GitHub.WebhookSecret, }) logger.Info("GitHub webhook handler registered") } else { diff --git a/svc/ctrl/worker/BUILD.bazel b/svc/ctrl/worker/BUILD.bazel index f9842ecdc4..e875a3db58 100644 --- a/svc/ctrl/worker/BUILD.bazel +++ b/svc/ctrl/worker/BUILD.bazel @@ -16,6 +16,7 @@ go_library( "//pkg/cache", "//pkg/clickhouse", "//pkg/clock", + "//pkg/config", "//pkg/db", "//pkg/healthcheck", "//pkg/logger", diff --git a/svc/ctrl/worker/config.go b/svc/ctrl/worker/config.go index e1be285e52..02b6dee9b1 100644 --- a/svc/ctrl/worker/config.go +++ b/svc/ctrl/worker/config.go @@ -6,6 +6,7 @@ import ( "github.com/unkeyed/unkey/pkg/assert" "github.com/unkeyed/unkey/pkg/clock" + "github.com/unkeyed/unkey/pkg/config" ) // Route53Config holds AWS Route53 configuration for ACME DNS-01 challenges. @@ -15,22 +16,22 @@ import ( type Route53Config struct { // Enabled determines whether Route53 DNS-01 challenges are used. // When true, wildcard certificates can be automatically obtained. - Enabled bool + Enabled bool `toml:"enabled"` // AccessKeyID is the AWS access key ID for Route53 API access. - AccessKeyID string + AccessKeyID string `toml:"access_key_id"` // SecretAccessKey is the AWS secret access key for Route53 API access. - SecretAccessKey string + SecretAccessKey string `toml:"secret_access_key"` // Region is the AWS region where Route53 hosted zones are located. // Example: "us-east-1", "us-west-2". - Region string + Region string `toml:"region" config:"default=us-east-1"` // HostedZoneID overrides automatic zone discovery. // Required when domains have complex CNAME setups that confuse // automatic zone lookup (e.g., wildcard CNAMEs to load balancers). - HostedZoneID string + HostedZoneID string `toml:"hosted_zone_id"` } // AcmeConfig holds configuration for ACME TLS certificate management. @@ -40,16 +41,16 @@ type Route53Config struct { type AcmeConfig struct { // Enabled determines whether ACME certificate management is active. // When true, certificates are automatically obtained and renewed. - Enabled bool + Enabled bool `toml:"enabled"` // EmailDomain is the domain used for ACME account emails. // Used for Let's Encrypt account registration and recovery. // Example: "unkey.com" creates "admin@unkey.com" for ACME account. - EmailDomain string + EmailDomain string `toml:"email_domain" config:"default=unkey.com"` // Route53 configures DNS-01 challenges through AWS Route53 API. // Enables wildcard certificates for domains hosted on Route53. - Route53 Route53Config + Route53 Route53Config `toml:"route53"` } // RestateConfig holds configuration for Restate workflow engine integration. @@ -60,20 +61,20 @@ type RestateConfig struct { // AdminURL is the Restate admin endpoint URL for service registration. // Used by the worker to register its workflow services. // Example: "http://restate:9070". - AdminURL string + AdminURL string `toml:"admin_url" config:"default=http://restate:9070"` // APIKey is the optional authentication key for Restate admin API requests. // If set, this key will be sent with all requests to the Restate admin API. - APIKey string + APIKey string `toml:"api_key"` // HttpPort is the port where the worker listens for Restate requests. // This is the internal Restate server port, not the health check port. - HttpPort int + HttpPort int `toml:"http_port" config:"default=9080,min=1,max=65535"` // RegisterAs is the service URL used for self-registration with Restate. // Allows Restate to discover and invoke this worker's services. // Example: "http://worker:9080". - RegisterAs string + RegisterAs string `toml:"register_as"` } // DepotConfig holds configuration for Depot.dev build service integration. @@ -83,12 +84,12 @@ type RestateConfig struct { type DepotConfig struct { // APIUrl is the Depot API endpoint URL for build operations. // Example: "https://api.depot.dev". - APIUrl string + APIUrl string `toml:"api_url"` // ProjectRegion is the geographic region for build storage. // Affects build performance and data residency. // Options: "us-east-1", "eu-central-1". Default: "us-east-1". - ProjectRegion string + ProjectRegion string `toml:"project_region" config:"default=us-east-1"` } // RegistryConfig holds container registry authentication configuration. @@ -98,15 +99,27 @@ type DepotConfig struct { type RegistryConfig struct { // URL is the container registry endpoint URL. // Example: "registry.depot.dev" or "https://registry.example.com". - URL string + URL string `toml:"url"` // Username is the registry authentication username. // Common values: "x-token" for token-based auth, or actual username. - Username string + Username string `toml:"username"` // Password is the registry password or authentication token. // Should be stored securely and rotated regularly. - Password string + Password string `toml:"password"` +} + +// ClickHouseConfig holds ClickHouse connection configuration. +type ClickHouseConfig struct { + // URL is the ClickHouse database connection string. + // Used for analytics and operational metrics storage. + URL string `toml:"url"` + + // AdminURL is the connection string for the ClickHouse admin user. + // Used by ClickhouseUserService to create/configure workspace users. + // Optional - if not set, ClickhouseUserService will not be enabled. + AdminURL string `toml:"admin_url"` } // BuildPlatform represents parsed container build platform specification. @@ -123,148 +136,139 @@ type BuildPlatform struct { Architecture string } -// Config holds configuration for the Restate worker service. +// GitHubConfig holds configuration for GitHub App integration. +type GitHubConfig struct { + // AppID is the GitHub App ID for authentication. + AppID int64 `toml:"app_id"` + + // PrivateKeyPEM is the GitHub App private key in PEM format. + PrivateKeyPEM string `toml:"private_key_pem"` +} + +// Enabled returns true only if ALL required GitHub App fields are configured. +// This ensures we never register the workflow with partial/insecure config. +func (c GitHubConfig) Enabled() bool { + return c.AppID != 0 && c.PrivateKeyPEM != "" +} + +// HeartbeatConfig holds Checkly heartbeat URLs for health monitoring. +type HeartbeatConfig struct { + // CertRenewalURL is the Checkly heartbeat URL for certificate renewal. + // When set, a heartbeat is sent after successful certificate renewal runs. + // Optional - if empty, no heartbeat is sent. + CertRenewalURL string `toml:"cert_renewal_url"` + + // QuotaCheckURL is the Checkly heartbeat URL for quota checks. + // When set, a heartbeat is sent after successful quota check runs. + // Optional - if empty, no heartbeat is sent. + QuotaCheckURL string `toml:"quota_check_url"` + + // KeyRefillURL is the Checkly heartbeat URL for key refill runs. + // When set, a heartbeat is sent after successful key refill runs. + // Optional - if empty, no heartbeat is sent. + KeyRefillURL string `toml:"key_refill_url"` +} + +// SlackConfig holds Slack webhook URLs for notifications. +type SlackConfig struct { + // QuotaCheckWebhookURL is the Slack webhook URL for quota exceeded notifications. + // When set, Slack notifications are sent when workspaces exceed their quota. + // Optional - if empty, no Slack notifications are sent. + QuotaCheckWebhookURL string `toml:"quota_check_webhook_url"` +} + +// Config holds the complete configuration for the Restate worker service. +// It is designed to be loaded from a TOML file using [config.Load]: // -// This comprehensive configuration structure defines all aspects of worker -// operation including database connections, vault integration, build backends, -// ACME certificate management, and Restate integration. +// cfg, err := config.Load[worker.Config]("/etc/unkey/unkey.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]. +// +// Clock is runtime-only and cannot be set through a config file. It is +// tagged toml:"-" and must be set programmatically after loading. type Config struct { // InstanceID is the unique identifier for this worker instance. // Used for logging, tracing, and cluster coordination. - InstanceID string + InstanceID string `toml:"instance_id"` // Region is the geographic region where this worker instance is running. // Used for logging and tracing context. - Region string - - // OtelEnabled determines whether OpenTelemetry is enabled. - // When true, traces and logs are sent to the configured OTLP endpoint. - OtelEnabled bool - - // OtelTraceSamplingRate controls what percentage of traces are sampled. - // Values range from 0.0 to 1.0, where 1.0 means all traces are sampled. - OtelTraceSamplingRate float64 + Region string `toml:"region"` // PrometheusPort specifies the port for exposing Prometheus metrics. // Set to 0 to disable metrics exposure. When enabled, metrics are served // on all interfaces (0.0.0.0) on the specified port. - PrometheusPort int - - // DatabasePrimary is the primary database connection string. - // Used for both read and write operations to persistent storage. - DatabasePrimary string - - // VaultURL is the URL of the remote vault service for secret encryption. - // Example: "https://vault.unkey.cloud". - VaultURL string - - // VaultToken is the authentication token for the remote vault service. - // Used for bearer authentication when calling vault RPCs. - VaultToken string - - // Acme configures automatic TLS certificate management. - // Enables Let's Encrypt integration for domain certificates. - Acme AcmeConfig + PrometheusPort int `toml:"prometheus_port"` // DefaultDomain is the fallback domain for system operations. // Used for sentinel deployment and automatic certificate bootstrapping. - DefaultDomain string + DefaultDomain string `toml:"default_domain" config:"default=unkey.app"` - // Restate configures workflow engine integration. - // Enables asynchronous deployment and certificate renewal workflows. - Restate RestateConfig - - // BuildPlatform defines the target architecture for container builds. + // BuildPlatformStr defines the target architecture for container builds. // Format: "linux/amd64", "linux/arm64". Only "linux" OS supported. - BuildPlatform string - - // Depot configures Depot.dev build service integration. - Depot DepotConfig - - // RegistryURL is the container registry URL for pulling images. - // Example: "registry.depot.dev" or "https://registry.example.com". - RegistryURL string - - // RegistryUsername is the username for container registry authentication. - // Common values: "x-token" for token-based auth or actual username. - RegistryUsername string - - // RegistryPassword is the password/token for container registry authentication. - // Should be stored securely (environment variable or secret management). - RegistryPassword string - - // ClickhouseURL is the ClickHouse database connection string. - // Used for analytics and operational metrics storage. - ClickhouseURL string - - // ClickhouseAdminURL is the connection string for the ClickHouse admin user. - // Used by ClickhouseUserService to create/configure workspace users. - // The admin user requires limited permissions: CREATE/ALTER/DROP for USER, - // QUOTA, ROW POLICY, and SETTINGS PROFILE, plus GRANT OPTION on analytics tables. - // Optional - if not set, ClickhouseUserService will not be enabled. - // Example: "clickhouse://unkey_user_admin:C57RqT5EPZBqCJkMxN9mEZZEzMPcw9yBlwhIizk99t7kx6uLi9rYmtWObsXzdl@clickhouse:9000/default" - ClickhouseAdminURL string + BuildPlatformStr string `toml:"build_platform" config:"default=linux/amd64"` // SentinelImage is the container image used for new sentinel deployments. // Overrides default sentinel image with custom build or registry. - SentinelImage string + SentinelImage string `toml:"sentinel_image" config:"default=ghcr.io/unkeyed/unkey:local"` // AvailableRegions is a list of available regions for deployments. // typically in the format "region.provider", ie "us-east-1.aws", "local.dev" - AvailableRegions []string + AvailableRegions []string `toml:"available_regions"` // CnameDomain is the base domain for custom domain CNAME targets. // Each custom domain gets a unique subdomain like "{random}.{CnameDomain}". - // For production: "unkey-dns.com" - // For local: "unkey.local" - CnameDomain string - - // GitHub configures GitHub App integration for webhook-triggered deployments. - GitHub GitHubConfig + CnameDomain string `toml:"cname_domain" config:"required,nonempty"` // AllowUnauthenticatedDeployments controls whether deployments can skip // GitHub authentication. Set to true only for local development. // Production should keep this false to require GitHub App authentication. - AllowUnauthenticatedDeployments bool + AllowUnauthenticatedDeployments bool `toml:"allow_unauthenticated_deployments"` - // Clock provides time operations for testing and scheduling. - // Use clock.RealClock{} for production deployments. - Clock clock.Clock + // Database configures MySQL connections. See [config.DatabaseConfig]. + Database config.DatabaseConfig `toml:"database"` - // CertRenewalHeartbeatURL is the Checkly heartbeat URL for certificate renewal. - // When set, a heartbeat is sent after successful certificate renewal runs. - // Optional - if empty, no heartbeat is sent. - CertRenewalHeartbeatURL string + // Vault configures the encryption/decryption service. See [config.VaultConfig]. + Vault config.VaultConfig `toml:"vault"` - // QuotaCheckHeartbeatURL is the Checkly heartbeat URL for quota checks. - // When set, a heartbeat is sent after successful quota check runs. - // Optional - if empty, no heartbeat is sent. - QuotaCheckHeartbeatURL string + // Otel configures OpenTelemetry export. See [config.OtelConfig]. + Otel config.OtelConfig `toml:"otel"` - // QuotaCheckSlackWebhookURL is the Slack webhook URL for quota exceeded notifications. - // When set, Slack notifications are sent when workspaces exceed their quota. - // Optional - if empty, no Slack notifications are sent. - QuotaCheckSlackWebhookURL string + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` - // KeyRefillHeartbeatURL is the Checkly heartbeat URL for key refill runs. - // When set, a heartbeat is sent after successful key refill runs. - // Optional - if empty, no heartbeat is sent. - KeyRefillHeartbeatURL string -} + // Acme configures automatic TLS certificate management. + // Enables Let's Encrypt integration for domain certificates. + Acme AcmeConfig `toml:"acme"` -// GitHubConfig holds configuration for GitHub App integration. -type GitHubConfig struct { - // AppID is the GitHub App ID for authentication. - AppID int64 + // Restate configures workflow engine integration. + // Enables asynchronous deployment and certificate renewal workflows. + Restate RestateConfig `toml:"restate"` - // PrivateKeyPEM is the GitHub App private key in PEM format. - PrivateKeyPEM string -} + // Depot configures Depot.dev build service integration. + Depot DepotConfig `toml:"depot"` -// Enabled returns true only if ALL required GitHub App fields are configured. -// This ensures we never register the workflow with partial/insecure config. -func (c GitHubConfig) Enabled() bool { - return c.AppID != 0 && c.PrivateKeyPEM != "" + // Registry configures container registry authentication. + Registry RegistryConfig `toml:"registry"` + + // ClickHouse configures ClickHouse connections. + ClickHouse ClickHouseConfig `toml:"clickhouse"` + + // GitHub configures GitHub App integration for webhook-triggered deployments. + GitHub GitHubConfig `toml:"github"` + + // Heartbeat configures Checkly heartbeat URLs for health monitoring. + Heartbeat HeartbeatConfig `toml:"heartbeat"` + + // Slack configures Slack webhook URLs for notifications. + Slack SlackConfig `toml:"slack"` + + // Clock provides time operations for testing and scheduling. + // Use clock.New() for production deployments. + Clock clock.Clock `toml:"-"` } // parseBuildPlatform validates and parses a build platform string. @@ -295,28 +299,23 @@ func parseBuildPlatform(buildPlatform string) (BuildPlatform, error) { // GetBuildPlatform returns the parsed build platform. // // This method returns the parsed BuildPlatform from the configured -// BuildPlatform string. Should only be called after Validate() succeeds -// to ensure the platform string is valid. +// BuildPlatformStr string. // -// Returns BuildPlatform with parsed platform and architecture components. -func (c Config) GetBuildPlatform() BuildPlatform { - parsed, _ := parseBuildPlatform(c.BuildPlatform) - return parsed +// Returns BuildPlatform with parsed platform and architecture components, +// or an error if the platform string is invalid. +func (c Config) GetBuildPlatform() (BuildPlatform, error) { + return parseBuildPlatform(c.BuildPlatformStr) } // GetRegistryConfig returns the registry configuration. // -// This method builds a RegistryConfig from the individual registry -// settings in the main Config struct. Should only be called after -// Validate() succeeds to ensure all required fields are present. +// This method returns the RegistryConfig from the main Config struct. +// Should only be called after Validate() succeeds to ensure all required +// fields are present. // // Returns RegistryConfig with URL, username, and password for container registry access. func (c Config) GetRegistryConfig() RegistryConfig { - return RegistryConfig{ - URL: c.RegistryURL, - Username: c.RegistryUsername, - Password: c.RegistryPassword, - } + return c.Registry } // GetDepotConfig returns the depot configuration. @@ -339,7 +338,7 @@ func (c Config) GetDepotConfig() DepotConfig { // // Returns an error if required fields are missing, invalid, or inconsistent. // Provides detailed error messages to help identify configuration issues. -func (c Config) Validate() error { +func (c *Config) Validate() error { // Validate Route53 configuration if enabled if c.Acme.Enabled && c.Acme.Route53.Enabled { if err := assert.All( @@ -351,13 +350,9 @@ func (c Config) Validate() error { } } - if err := assert.NotEmpty(c.ClickhouseURL, "ClickhouseURL is required"); err != nil { - return err - } - // Validate build platform format (only if configured) - if c.BuildPlatform != "" { - if _, err := parseBuildPlatform(c.BuildPlatform); err != nil { + if c.BuildPlatformStr != "" { + if _, err := parseBuildPlatform(c.BuildPlatformStr); err != nil { return err } } @@ -365,10 +360,10 @@ func (c Config) Validate() error { // Validate build configuration (Depot backend) - only if registry password is provided // The registry password is the depot token, which is required for builds. // URL and username may be hardcoded in k8s manifests, password comes from secrets. - if c.RegistryPassword != "" { + if c.Registry.Password != "" { if err := assert.All( - assert.NotEmpty(c.RegistryURL, "registry URL is required when registry password is configured"), - assert.NotEmpty(c.RegistryUsername, "registry username is required when registry password is configured"), + assert.NotEmpty(c.Registry.URL, "registry URL is required when registry password is configured"), + assert.NotEmpty(c.Registry.Username, "registry username is required when registry password is configured"), assert.NotEmpty(c.Depot.APIUrl, "Depot API URL is required when registry password is configured"), assert.NotEmpty(c.Depot.ProjectRegion, "Depot project region is required when registry password is configured"), ); err != nil { diff --git a/svc/ctrl/worker/doc.go b/svc/ctrl/worker/doc.go index be25228edf..cb96a32325 100644 --- a/svc/ctrl/worker/doc.go +++ b/svc/ctrl/worker/doc.go @@ -14,20 +14,22 @@ // // # Configuration // -// Configuration is provided through [Config], which validates settings on startup. The worker -// supports multiple build backends and validates their requirements in [Config.Validate]. +// Configuration is loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[worker.Config]("/etc/unkey/unkey.toml") +// +// The worker validates settings through struct tags and [Config.Validate]. // // # Usage // // The worker is started with [Run], which blocks until the context is cancelled or a fatal // error occurs: // -// cfg := worker.Config{ -// InstanceID: "worker-1", -// HttpPort: 7092, -// DatabasePrimary: "mysql://...", -// // ... additional configuration +// cfg, err := config.Load[worker.Config]("unkey.toml") +// if err != nil { +// log.Fatal(err) // } +// cfg.Clock = clock.New() // // if err := worker.Run(ctx, cfg); err != nil { // log.Fatal(err) diff --git a/svc/ctrl/worker/run.go b/svc/ctrl/worker/run.go index 085abc6604..61dce44c8f 100644 --- a/svc/ctrl/worker/run.go +++ b/svc/ctrl/worker/run.go @@ -64,11 +64,6 @@ import ( // fails, or during server startup. Context cancellation results in // clean shutdown with nil error. func Run(ctx context.Context, cfg Config) error { - err := cfg.Validate() - if err != nil { - return fmt.Errorf("bad config: %w", err) - } - // Disable CNAME following in lego to prevent it from following wildcard CNAMEs // (e.g., *.example.com -> loadbalancer.aws.com) and failing Route53 zone lookup. // Must be set before creating any ACME DNS providers. @@ -76,13 +71,14 @@ func Run(ctx context.Context, cfg Config) error { // Initialize OTEL before logger so logger picks up OTLP handler var shutdownGrafana func(context.Context) error - if cfg.OtelEnabled { + var err error + if cfg.Otel.Enabled { shutdownGrafana, err = otel.InitGrafana(ctx, otel.Config{ Application: "worker", Version: version.Version, InstanceID: cfg.InstanceID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -103,21 +99,21 @@ func Run(ctx context.Context, cfg Config) error { // Create vault client for remote vault service var vaultClient vaultv1connect.VaultServiceClient - if cfg.VaultURL != "" { + if cfg.Vault.URL != "" { vaultClient = vaultv1connect.NewVaultServiceClient( http.DefaultClient, - cfg.VaultURL, + cfg.Vault.URL, connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ - "Authorization": "Bearer " + cfg.VaultToken, + "Authorization": "Bearer " + cfg.Vault.Token, })), ) - logger.Info("Vault client initialized", "url", cfg.VaultURL) + logger.Info("Vault client initialized", "url", cfg.Vault.URL) } // Initialize database database, err := db.New(db.Config{ - PrimaryDSN: cfg.DatabasePrimary, - ReadOnlyDSN: "", + PrimaryDSN: cfg.Database.Primary, + ReadOnlyDSN: cfg.Database.ReadonlyReplica, }) if err != nil { return fmt.Errorf("unable to create db: %w", err) @@ -143,9 +139,9 @@ func Run(ctx context.Context, cfg Config) error { } var ch clickhouse.ClickHouse = clickhouse.NewNoop() - if cfg.ClickhouseURL != "" { + if cfg.ClickHouse.URL != "" { chClient, chErr := clickhouse.New(clickhouse.Config{ - URL: cfg.ClickhouseURL, + URL: cfg.ClickHouse.URL, }) if chErr != nil { logger.Error("failed to create clickhouse client, continuing with noop", "error", chErr) @@ -157,6 +153,11 @@ func Run(ctx context.Context, cfg Config) error { // Restate Server - uses logging.GetHandler() for slog integration restateSrv := restateServer.NewRestate().WithLogger(logger.GetHandler(), false) + buildPlatform, err := cfg.GetBuildPlatform() + if err != nil { + return fmt.Errorf("invalid build platform: %w", err) + } + restateSrv.Bind(hydrav1.NewDeployServiceServer(deploy.New(deploy.Config{ DB: database, DefaultDomain: cfg.DefaultDomain, @@ -165,7 +166,7 @@ func Run(ctx context.Context, cfg Config) error { AvailableRegions: cfg.AvailableRegions, GitHub: ghClient, RegistryConfig: deploy.RegistryConfig(cfg.GetRegistryConfig()), - BuildPlatform: deploy.BuildPlatform(cfg.GetBuildPlatform()), + BuildPlatform: deploy.BuildPlatform(buildPlatform), DepotConfig: deploy.DepotConfig(cfg.GetDepotConfig()), Clickhouse: ch, AllowUnauthenticatedDeployments: cfg.AllowUnauthenticatedDeployments, @@ -256,8 +257,8 @@ func Run(ctx context.Context, cfg Config) error { // Certificate service needs a longer timeout for ACME DNS-01 challenges // which can take 5-10 minutes for DNS propagation var certHeartbeat healthcheck.Heartbeat = healthcheck.NewNoop() - if cfg.CertRenewalHeartbeatURL != "" { - certHeartbeat = healthcheck.NewChecklyHeartbeat(cfg.CertRenewalHeartbeatURL) + if cfg.Heartbeat.CertRenewalURL != "" { + certHeartbeat = healthcheck.NewChecklyHeartbeat(cfg.Heartbeat.CertRenewalURL) } restateSrv.Bind(hydrav1.NewCertificateServiceServer(certificate.New(certificate.Config{ DB: database, @@ -270,13 +271,13 @@ func Run(ctx context.Context, cfg Config) error { }), restate.WithInactivityTimeout(15*time.Minute))) // ClickHouse user provisioning service (optional - requires admin URL and vault) - if cfg.ClickhouseAdminURL == "" { - logger.Info("ClickhouseUserService disabled: CLICKHOUSE_ADMIN_URL not configured") + if cfg.ClickHouse.AdminURL == "" { + logger.Info("ClickhouseUserService disabled: clickhouse admin_url not configured") } else if vaultClient == nil { logger.Warn("ClickhouseUserService disabled: vault not configured") } else { chAdmin, chAdminErr := clickhouse.New(clickhouse.Config{ - URL: cfg.ClickhouseAdminURL, + URL: cfg.ClickHouse.AdminURL, }) if chAdminErr != nil { logger.Warn("ClickhouseUserService disabled: failed to connect to admin", @@ -294,14 +295,14 @@ func Run(ctx context.Context, cfg Config) error { // Quota check service for monitoring workspace usage var quotaHeartbeat healthcheck.Heartbeat = healthcheck.NewNoop() - if cfg.QuotaCheckHeartbeatURL != "" { - quotaHeartbeat = healthcheck.NewChecklyHeartbeat(cfg.QuotaCheckHeartbeatURL) + if cfg.Heartbeat.QuotaCheckURL != "" { + quotaHeartbeat = healthcheck.NewChecklyHeartbeat(cfg.Heartbeat.QuotaCheckURL) } quotaCheckSvc, err := quotacheck.New(quotacheck.Config{ DB: database, Clickhouse: ch, Heartbeat: quotaHeartbeat, - SlackWebhookURL: cfg.QuotaCheckSlackWebhookURL, + SlackWebhookURL: cfg.Slack.QuotaCheckWebhookURL, }) if err != nil { return fmt.Errorf("create quota check service: %w", err) @@ -311,8 +312,8 @@ func Run(ctx context.Context, cfg Config) error { // Key refill service for scheduled key usage limit refills var keyRefillHeartbeat healthcheck.Heartbeat = healthcheck.NewNoop() - if cfg.KeyRefillHeartbeatURL != "" { - keyRefillHeartbeat = healthcheck.NewChecklyHeartbeat(cfg.KeyRefillHeartbeatURL) + if cfg.Heartbeat.KeyRefillURL != "" { + keyRefillHeartbeat = healthcheck.NewChecklyHeartbeat(cfg.Heartbeat.KeyRefillURL) } keyRefillSvc, err := keyrefill.New(keyrefill.Config{ diff --git a/svc/frontline/BUILD.bazel b/svc/frontline/BUILD.bazel index f0544ab1ed..ec7a44ff6e 100644 --- a/svc/frontline/BUILD.bazel +++ b/svc/frontline/BUILD.bazel @@ -12,6 +12,7 @@ go_library( "//gen/proto/ctrl/v1/ctrlv1connect", "//gen/proto/vault/v1/vaultv1connect", "//pkg/clock", + "//pkg/config", "//pkg/db", "//pkg/logger", "//pkg/otel", diff --git a/svc/frontline/config.go b/svc/frontline/config.go index 3fd289dcf6..03a6f06aa6 100644 --- a/svc/frontline/config.go +++ b/svc/frontline/config.go @@ -1,87 +1,91 @@ package frontline -import "time" +import ( + "fmt" + + "github.com/unkeyed/unkey/pkg/config" +) + +// TLSConfig controls TLS termination for the frontline server. When Enabled +// is true and both CertFile and KeyFile are set, the server uses static +// file-based TLS (dev mode). When only Enabled is true and a vault/cert +// manager is available, dynamic certificates are used (production mode). +type TLSConfig struct { + // Enabled activates TLS termination. Defaults to true. + Enabled bool `toml:"enabled" config:"default=true"` + + // CertFile is the filesystem path to a PEM-encoded TLS certificate. + // Used together with KeyFile for static file-based TLS (dev mode). + CertFile string `toml:"cert_file"` + + // KeyFile is the filesystem path to a PEM-encoded TLS private key. + // Used together with CertFile for static file-based TLS (dev mode). + KeyFile string `toml:"key_file"` +} +// Config holds the complete configuration for the frontline server. It is +// designed to be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[frontline.Config]("/etc/unkey/frontline.toml") +// +// FrontlineID and Image are runtime-only fields set programmatically after +// loading and tagged toml:"-". type Config struct { - // FrontlineID is the unique identifier for this instance of the Frontline server - FrontlineID string + // FrontlineID is the unique identifier for this instance of the frontline + // server. Set at runtime; not read from the config file. + FrontlineID string `toml:"-"` - // Image specifies the container image identifier including repository and tag - Image string + // Image is the container image identifier including repository and tag. + // Set at runtime; not read from the config file. + Image string `toml:"-"` - // HttpPort defines the HTTP port for the Gate server to listen on (default: 7070) - HttpPort int + // HttpPort is the TCP port the HTTP challenge server binds to. + HttpPort int `toml:"http_port" config:"default=7070,min=1,max=65535"` - // HttpsPort defines the HTTPS port for the Gate server to listen on (default: 7443) - HttpsPort int + // HttpsPort is the TCP port the HTTPS frontline server binds to. + HttpsPort int `toml:"https_port" config:"default=7443,min=1,max=65535"` // Region identifies the geographic region where this node is deployed. - // Used for observability, latency optimization, and compliance requirements. - // Must match the region identifier used by the underlying cloud platform - // and control plane configuration. - Region string - - // EnableTLS specifies whether TLS should be enabled for the Frontline server - EnableTLS bool - - // TLSCertFile is the path to a static TLS certificate file (for dev mode) - // When set along with TLSKeyFile, frontline uses file-based TLS instead of dynamic certs - TLSCertFile string - - // TLSKeyFile is the path to a static TLS key file (for dev mode) - // When set along with TLSCertFile, frontline uses file-based TLS instead of dynamic certs - TLSKeyFile string - - // ApexDomain is the apex domain for region routing (e.g., unkey.cloud) - // Cross-region requests are forwarded to frontline.{region}.{ApexDomain} - // Example: frontline.us-east-1.aws.unkey.cloud - ApexDomain string - - // MaxHops is the maximum number of frontline hops allowed before rejecting the request - // This prevents infinite routing loops. Default: 3 - MaxHops int - - // -- Control Plane Configuration --- - - // CtrlAddr is the address of the control plane (e.g., control.unkey.com) - CtrlAddr string - - // --- Database configuration --- - - // DatabasePrimary is the primary database connection string for read and write operations - DatabasePrimary string - - // DatabaseReadonlyReplica is an optional read-replica database connection string for read operations - DatabaseReadonlyReplica string - - // --- OpenTelemetry configuration --- + // Used for observability, latency optimization, and cross-region routing. + Region string `toml:"region" config:"required"` - // OtelEnabled specifies whether OpenTelemetry tracing is enabled - OtelEnabled bool + // ApexDomain is the apex domain for region routing. Cross-region requests + // are forwarded to frontline.{region}.{ApexDomain}. + ApexDomain string `toml:"apex_domain" config:"default=unkey.cloud"` - // OtelTraceSamplingRate specifies the sampling rate for OpenTelemetry traces (0.0 - 1.0) - OtelTraceSamplingRate float64 + // MaxHops is the maximum number of frontline hops allowed before rejecting + // the request. Prevents infinite routing loops. + MaxHops int `toml:"max_hops" config:"default=10"` - // PrometheusPort specifies the port for Prometheus metrics - PrometheusPort int + // CtrlAddr is the address of the control plane service. + CtrlAddr string `toml:"ctrl_addr" config:"default=localhost:8080"` - // --- Vault Configuration --- + // PrometheusPort starts a Prometheus /metrics HTTP endpoint on the + // specified port. Set to 0 to disable. + PrometheusPort int `toml:"prometheus_port"` - // VaultURL is the URL of the remote vault service (e.g., http://vault:8080) - VaultURL string + // TLS controls TLS termination. See [TLSConfig]. + TLS TLSConfig `toml:"tls"` - // VaultToken is the authentication token for the vault service - VaultToken string + // Database configures MySQL connections. See [config.DatabaseConfig]. + Database config.DatabaseConfig `toml:"database"` - // --- 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 + // Vault configures the encryption/decryption service. See [config.VaultConfig]. + Vault config.VaultConfig `toml:"vault"` - // 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 { +// 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 { + if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") { + return fmt.Errorf("both tls.cert_file and tls.key_file must be provided together") + } return nil } diff --git a/svc/frontline/run.go b/svc/frontline/run.go index 37606de131..ee7b3d0890 100644 --- a/svc/frontline/run.go +++ b/svc/frontline/run.go @@ -43,8 +43,8 @@ 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, }) // Create cached clock with millisecond resolution for efficient time tracking @@ -52,13 +52,13 @@ func Run(ctx context.Context, cfg Config) error { // 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: "frontline", Version: version.Version, InstanceID: cfg.FrontlineID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -105,22 +105,22 @@ func Run(ctx context.Context, cfg Config) error { } var vaultClient vaultv1connect.VaultServiceClient - if cfg.VaultURL != "" { + if cfg.Vault.URL != "" { vaultClient = vaultv1connect.NewVaultServiceClient( http.DefaultClient, - cfg.VaultURL, + cfg.Vault.URL, connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ - "Authorization": "Bearer " + cfg.VaultToken, + "Authorization": "Bearer " + cfg.Vault.Token, })), ) - logger.Info("Vault client initialized", "url", cfg.VaultURL) + logger.Info("Vault client initialized", "url", cfg.Vault.URL) } else { logger.Warn("Vault not configured - TLS certificate decryption will be unavailable") } db, 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 partitioned db: %w", err) @@ -172,17 +172,17 @@ func Run(ctx context.Context, cfg Config) error { // Create TLS config - either from static files (dev mode) or dynamic certificates (production) var tlsConfig *pkgtls.Config - if cfg.EnableTLS { - if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { + if cfg.TLS.Enabled { + if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { // Dev mode: static file-based certificate - fileTLSConfig, tlsErr := pkgtls.NewFromFiles(cfg.TLSCertFile, cfg.TLSKeyFile) + fileTLSConfig, tlsErr := pkgtls.NewFromFiles(cfg.TLS.CertFile, cfg.TLS.KeyFile) if tlsErr != nil { return fmt.Errorf("failed to load TLS certificate from files: %w", tlsErr) } tlsConfig = fileTLSConfig logger.Info("TLS configured with static certificate files", - "certFile", cfg.TLSCertFile, - "keyFile", cfg.TLSKeyFile) + "certFile", cfg.TLS.CertFile, + "keyFile", cfg.TLS.KeyFile) } else if certManager != nil { // Production mode: dynamic certificates from database/vault //nolint:exhaustruct diff --git a/svc/krane/BUILD.bazel b/svc/krane/BUILD.bazel index dc77a50c05..ac97d0d573 100644 --- a/svc/krane/BUILD.bazel +++ b/svc/krane/BUILD.bazel @@ -13,6 +13,7 @@ go_library( "//gen/proto/krane/v1/kranev1connect", "//gen/proto/vault/v1/vaultv1connect", "//pkg/clock", + "//pkg/config", "//pkg/logger", "//pkg/otel", "//pkg/prometheus", diff --git a/svc/krane/config.go b/svc/krane/config.go index df5e3d2e59..bbfd39b1dd 100644 --- a/svc/krane/config.go +++ b/svc/krane/config.go @@ -1,94 +1,82 @@ package krane import ( - "time" - "github.com/unkeyed/unkey/pkg/clock" + "github.com/unkeyed/unkey/pkg/config" ) -// Config holds configuration for the krane agent server. +// RegistryConfig holds credentials for the container image registry used when +// pulling deployment images. All fields are optional; when URL is empty, the +// default registry configured on the cluster is used. +type RegistryConfig struct { + // URL is the container registry endpoint (e.g. "registry.depot.dev"). + URL string `toml:"url"` + + // Username is the registry authentication username (e.g. "x-token"). + Username string `toml:"username"` + + // Password is the registry authentication password or token. + Password string `toml:"password"` +} + +// ControlPlaneConfig configures the connection to the control plane that streams +// deployment and sentinel state to krane agents. +type ControlPlaneConfig struct { + // URL is the control plane endpoint. + URL string `toml:"url" config:"default=https://control.unkey.cloud"` + + // Bearer is the authentication token for the control plane API. + Bearer string `toml:"bearer" config:"required,nonempty"` +} + +// Config holds the complete configuration for the krane agent. It is designed +// to be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[krane.Config]("/etc/unkey/krane.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]. // -// This configuration defines how the krane agent connects to Kubernetes, -// authenticates with container registries, handles secrets, and exposes metrics. +// The Clock field is runtime-only and cannot be set through a config file. type Config struct { // InstanceID is the unique identifier for this krane agent instance. - // Used for distributed tracing, logging correlation, and cluster coordination. - // Must be unique across all running krane instances in the same cluster. - InstanceID string + InstanceID string `toml:"instance_id"` // Region identifies the geographic region where this node is deployed. - // Used for observability, latency optimization, and compliance requirements. - // Must match the region identifier used by the underlying cloud platform - // and control plane configuration. - Region string - - // RegistryURL is the URL of the container registry for pulling images. - // Should include the protocol and registry domain, e.g., "registry.depot.dev" - // or "https://registry.example.com". Used by all deployments unless overridden. - RegistryURL string - - // RegistryUsername is the username for authenticating with the container registry. - // Common values include "x-token" for token-based authentication or the - // actual registry username. Must be paired with RegistryPassword. - RegistryUsername string - - // RegistryPassword is the password or token for authenticating with the container registry. - // Should be stored securely (e.g., environment variable or secret management system). - // For token-based auth, this is the actual token value. - RegistryPassword string - - // Clock provides time operations for testing and time zone handling. - // Use clock.RealClock{} for production deployments and mock clocks for - // deterministic testing. Enables time-based operations to be controlled in tests. - Clock clock.Clock - - // PrometheusPort specifies the port for exposing Prometheus metrics. - // Set to 0 to disable metrics exposure. When enabled, metrics are served - // on all interfaces (0.0.0.0) on the specified port. - PrometheusPort int - - // VaultURL is the URL of the remote vault service (e.g., http://vault:8080). - // Required for decrypting environment variable secrets. - VaultURL string - - // VaultToken is the authentication token for the vault service. - // Used to authenticate requests to the vault API. - VaultToken string - - // RPCPort specifies the port for the gRPC server that exposes krane APIs. - // The SchedulerService and optionally SecretsService are served on this port. - // Must be a valid port number (1-65535). - RPCPort int - - ControlPlaneURL string - ControlPlaneBearer string - - // OtelEnabled enables OpenTelemetry instrumentation for tracing and metrics. - // When true, InitGrafana will be called to set up OTEL exporters. - OtelEnabled bool - - // OtelTraceSamplingRate controls the sampling rate for traces (0.0 to 1.0). - // Only used when OtelEnabled is true. - OtelTraceSamplingRate float64 - - // --- Logging sampler configuration --- - - // 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 + Region string `toml:"region" config:"required,nonempty"` + + // RPCPort is the TCP port for the gRPC server. + RPCPort int `toml:"rpc_port" config:"default=8070,min=1,max=65535"` + + // PrometheusPort starts a Prometheus /metrics endpoint on the specified + // port. Set to 0 to disable. + PrometheusPort int `toml:"prometheus_port"` + + // Registry configures container image registry access. See [RegistryConfig]. + Registry RegistryConfig `toml:"registry"` + + // Vault configures the secrets decryption service. See [config.VaultConfig]. + Vault config.VaultConfig `toml:"vault"` + + // ControlPlane configures the upstream control plane. See [ControlPlaneConfig]. + ControlPlane ControlPlaneConfig `toml:"control_plane"` + + // Otel configures OpenTelemetry export. See [config.OtelConfig]. + Otel config.OtelConfig `toml:"otel"` + + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` + + // Clock provides time operations and is injected for testability. Production + // callers set this to [clock.New]; tests can substitute a fake clock. + Clock clock.Clock `toml:"-"` } -// Validate checks the configuration for required fields and logical consistency. -// -// Returns an error if required fields are missing or configuration values are invalid. -// This method should be called before starting the krane agent to ensure -// proper configuration and provide early feedback on configuration errors. -// -// Currently, this method always returns nil as validation is not implemented. -// Future implementations will validate required fields such as RPCPort, -// RegistryURL, and consistency between VaultMasterKeys and VaultS3 configuration. -func (c Config) Validate() error { +// 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 { return nil } diff --git a/svc/krane/doc.go b/svc/krane/doc.go index a5d11ed912..ec188b2048 100644 --- a/svc/krane/doc.go +++ b/svc/krane/doc.go @@ -54,23 +54,10 @@ // // Basic krane agent setup: // -// cfg := krane.Config{ -// InstanceID: "krane-node-001", -// Region: "us-west-2", -// RegistryURL: "registry.depot.dev", -// RegistryUsername: "x-token", -// RegistryPassword: "depot-token", -// RPCPort: 8080, -// PrometheusPort: 9090, -// VaultMasterKeys: []string{"master-key-1"}, -// VaultS3: krane.S3Config{ -// URL: "https://s3.amazonaws.com", -// Bucket: "krane-vault", -// AccessKeyID: "access-key", -// AccessKeySecret: "secret-key", -// }, -// } -// err := krane.Run(context.Background(), cfg) +// cfg, err := config.Load[krane.Config]("/etc/unkey/krane.toml") +// if err != nil { ... } +// cfg.Clock = clock.New() +// err = krane.Run(context.Background(), cfg) // // The agent will: // 1. Initialize Kubernetes client using in-cluster configuration diff --git a/svc/krane/run.go b/svc/krane/run.go index 2cda5c19c0..f5a96ec136 100644 --- a/svc/krane/run.go +++ b/svc/krane/run.go @@ -57,18 +57,18 @@ 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, }) var shutdownGrafana func(context.Context) error - if cfg.OtelEnabled { + if cfg.Otel.Enabled { shutdownGrafana, err = otel.InitGrafana(ctx, otel.Config{ Application: "krane", Version: pkgversion.Version, InstanceID: cfg.InstanceID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -92,8 +92,8 @@ func Run(ctx context.Context, cfg Config) error { r.DeferCtx(shutdownGrafana) cluster := controlplane.NewClient(controlplane.ClientConfig{ - URL: cfg.ControlPlaneURL, - BearerToken: cfg.ControlPlaneBearer, + URL: cfg.ControlPlane.URL, + BearerToken: cfg.ControlPlane.Bearer, Region: cfg.Region, }) @@ -149,15 +149,15 @@ func Run(ctx context.Context, cfg Config) error { // Create vault client for secrets decryption var vaultClient vaultv1connect.VaultServiceClient - if cfg.VaultURL != "" { + if cfg.Vault.URL != "" { vaultClient = vaultv1connect.NewVaultServiceClient( http.DefaultClient, - cfg.VaultURL, + cfg.Vault.URL, connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ - "Authorization": "Bearer " + cfg.VaultToken, + "Authorization": "Bearer " + cfg.Vault.Token, })), ) - logger.Info("Vault client initialized", "url", cfg.VaultURL) + logger.Info("Vault client initialized", "url", cfg.Vault.URL) } // Create the connect handler diff --git a/svc/preflight/BUILD.bazel b/svc/preflight/BUILD.bazel index 0d050223e8..6936ecee7f 100644 --- a/svc/preflight/BUILD.bazel +++ b/svc/preflight/BUILD.bazel @@ -9,7 +9,7 @@ go_library( importpath = "github.com/unkeyed/unkey/svc/preflight", visibility = ["//visibility:public"], deps = [ - "//pkg/assert", + "//pkg/config", "//pkg/logger", "//pkg/runner", "//pkg/tls", diff --git a/svc/preflight/config.go b/svc/preflight/config.go index 0419ff36c4..ad5a75a084 100644 --- a/svc/preflight/config.go +++ b/svc/preflight/config.go @@ -1,41 +1,79 @@ package preflight import ( - "time" - - "github.com/unkeyed/unkey/pkg/assert" + "github.com/unkeyed/unkey/pkg/config" ) -var validImagePullPolicies = map[string]bool{ - "Always": true, - "IfNotPresent": true, - "Never": true, +// TLSConfig holds filesystem paths for the TLS certificate and private key +// used by the webhook HTTPS server. +type TLSConfig struct { + // CertFile is the path to a PEM-encoded TLS certificate. + CertFile string `toml:"cert_file" config:"required,nonempty"` + + // KeyFile is the path to a PEM-encoded TLS private key. + KeyFile string `toml:"key_file" config:"required,nonempty"` +} + +// InjectConfig controls the container image injected into mutated pods by the +// admission webhook. +type InjectConfig struct { + // Image is the container image reference for the inject binary. + Image string `toml:"image" config:"default=inject:latest"` + + // ImagePullPolicy is the Kubernetes image pull policy applied to the + // injected init container. + ImagePullPolicy string `toml:"image_pull_policy" config:"default=IfNotPresent,oneof=Always|IfNotPresent|Never"` +} + +// RegistryConfig configures container registry behavior for the preflight +// webhook, including insecure registries and alias mappings. +type RegistryConfig struct { + // InsecureRegistries is a list of registry hostnames that should be + // contacted over plain HTTP instead of HTTPS. + InsecureRegistries []string `toml:"insecure_registries"` + + // Aliases is a list of registry alias mappings in "from=to" format. + // The webhook rewrites image references matching the left-hand side to + // the right-hand side before pulling. + Aliases []string `toml:"aliases"` } +// Config holds the complete configuration for the preflight admission webhook +// server. It is designed to be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[preflight.Config]("/etc/unkey/preflight.toml") +// +// Environment variables are expanded in file values using ${VAR} or +// ${VAR:-default} syntax before parsing. type Config struct { - HttpPort int - TLSCertFile string - TLSKeyFile string - InjectImage string - InjectImagePullPolicy string - KraneEndpoint string - DepotToken string - InsecureRegistries []string - RegistryAliases []string - - // --- Logging sampler configuration --- - - // 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 + // HttpPort is the TCP port the webhook HTTPS server binds to. + HttpPort int `toml:"http_port" config:"default=8443,min=1,max=65535"` + + // KraneEndpoint is the URL of the Krane secrets provider service. + KraneEndpoint string `toml:"krane_endpoint" config:"default=http://krane.unkey.svc.cluster.local:8070"` + + // DepotToken is an optional Depot API token for fetching on-demand + // container registry pull tokens. + DepotToken string `toml:"depot_token"` + + // TLS provides filesystem paths for HTTPS certificate and key. + // See [TLSConfig]. + TLS TLSConfig `toml:"tls"` + + // Inject controls the container image injected into mutated pods. + // See [InjectConfig]. + Inject InjectConfig `toml:"inject"` + + // Registry configures container registry behavior. See [RegistryConfig]. + Registry RegistryConfig `toml:"registry"` + + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` } +// Validate implements [config.Validator] so that [config.Load] calls it +// automatically after tag-level validation. All constraints are expressed +// through struct tags, so this method is a no-op. func (c *Config) Validate() error { - if c.HttpPort == 0 { - c.HttpPort = 8443 - } - - return assert.True(validImagePullPolicies[c.InjectImagePullPolicy], "inject-image-pull-policy must be one of: Always, IfNotPresent, Never") + return nil } diff --git a/svc/preflight/run.go b/svc/preflight/run.go index fed4357d9e..a125cec438 100644 --- a/svc/preflight/run.go +++ b/svc/preflight/run.go @@ -25,8 +25,8 @@ 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, }) r := runner.New() @@ -55,11 +55,11 @@ func Run(ctx context.Context, cfg Config) error { reg := registry.New(registry.Config{ Clientset: clientset, Credentials: credentialsManager, - InsecureRegistries: cfg.InsecureRegistries, - RegistryAliases: cfg.RegistryAliases, + InsecureRegistries: cfg.Registry.InsecureRegistries, + RegistryAliases: cfg.Registry.Aliases, }) - tlsConfig, err := tls.NewFromFiles(cfg.TLSCertFile, cfg.TLSKeyFile) + tlsConfig, err := tls.NewFromFiles(cfg.TLS.CertFile, cfg.TLS.KeyFile) if err != nil { return fmt.Errorf("failed to load TLS certificates: %w", err) } @@ -78,8 +78,8 @@ func Run(ctx context.Context, cfg Config) error { Registry: reg, Clientset: clientset, Credentials: credentialsManager, - InjectImage: cfg.InjectImage, - InjectImagePullPolicy: cfg.InjectImagePullPolicy, + InjectImage: cfg.Inject.Image, + InjectImagePullPolicy: cfg.Inject.ImagePullPolicy, DefaultProviderEndpoint: cfg.KraneEndpoint, }) 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) diff --git a/svc/vault/BUILD.bazel b/svc/vault/BUILD.bazel index 95556406c6..7e37fe983b 100644 --- a/svc/vault/BUILD.bazel +++ b/svc/vault/BUILD.bazel @@ -10,7 +10,7 @@ go_library( visibility = ["//visibility:public"], deps = [ "//gen/proto/vault/v1/vaultv1connect", - "//pkg/assert", + "//pkg/config", "//pkg/logger", "//pkg/otel", "//pkg/runner", diff --git a/svc/vault/config.go b/svc/vault/config.go index 2c4ac2ad9e..07c4608bdb 100644 --- a/svc/vault/config.go +++ b/svc/vault/config.go @@ -1,63 +1,67 @@ package vault import ( - "time" - - "github.com/unkeyed/unkey/pkg/assert" + "github.com/unkeyed/unkey/pkg/config" ) -type Config struct { - // InstanceID is the unique identifier for this instance of the API server - InstanceID string - - // HttpPort defines the HTTP port for the API server to listen on (default: 7070) - HttpPort int - - // Region is the cloud region where this instance is running - Region string - - // OtelEnabled enables OpenTelemetry instrumentation - OtelEnabled bool - - // OtelTraceSamplingRate is the sampling rate for traces (0.0 to 1.0) - OtelTraceSamplingRate float64 - - // S3Bucket is the bucket to store secrets in - S3Bucket string - // S3URL is the url to store secrets in - S3URL string - // S3AccessKeyID is the access key id to use for s3 - S3AccessKeyID string - // S3AccessKeySecret is the access key secret to use for s3 - S3AccessKeySecret string - // MasterKeys - // The first key is used for encryption, additional keys may be provided for backwards compatible decryption - // - // If multiple keys are provided, vault will start a rekey process to migrate all secrets to the new key - MasterKeys []string - // BearerToken is the authentication token for securing vault operations - BearerToken string - - // --- Logging sampler configuration --- - - // 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 +// S3Config configures the S3-compatible object storage backend used by vault to +// persist encrypted secrets. All fields are required. +type S3Config struct { + // URL is the S3-compatible endpoint URL. + // Example: "http://s3:3902" + URL string `toml:"url" config:"required,nonempty"` + + // Bucket is the S3 bucket name for storing encrypted secrets. + Bucket string `toml:"bucket" config:"required,nonempty"` + + // AccessKeyID is the access key ID for authenticating with S3. + AccessKeyID string `toml:"access_key_id" config:"required,nonempty"` + + // AccessKeySecret is the secret access key for authenticating with S3. + AccessKeySecret string `toml:"access_key_secret" config:"required,nonempty"` } -func (c Config) Validate() error { +// Config holds the complete configuration for the vault service. It is designed +// to be loaded from a TOML file using [config.Load]: +// +// cfg, err := config.Load[vault.Config]("/etc/unkey/vault.toml") +// +// Environment variables are expanded in file values using ${VAR} or +// ${VAR:-default} syntax before parsing. +type Config struct { + // InstanceID identifies this particular vault instance. Used in log + // attribution and observability labels. + InstanceID string `toml:"instance_id" config:"required,nonempty"` + + // HttpPort is the TCP port the vault server binds to. + HttpPort int `toml:"http_port" config:"default=8060,min=1,max=65535"` - return assert.All( - assert.NotEmpty(c.InstanceID, "instanceID must not be empty"), - assert.Greater(c.HttpPort, 0, "httpPort must be greater than 0"), - assert.NotEmpty(c.S3Bucket, "s3Bucket must not be empty"), - assert.NotEmpty(c.S3URL, "s3Url must not be empty"), - assert.NotEmpty(c.S3AccessKeyID, "s3AccessKeyID must not be empty"), - assert.NotEmpty(c.S3AccessKeySecret, "s3AccessKeySecret must not be empty"), - assert.NotEmpty(c.MasterKeys, "masterKeys must not be empty"), - assert.NotEmpty(c.BearerToken, "bearerToken must not be empty"), - ) + // Region is the geographic region identifier (e.g. "us-east-1"). + // Included in structured logs and OpenTelemetry attributes. + Region string `toml:"region"` + + // BearerToken is the authentication token for securing vault operations. + BearerToken string `toml:"bearer_token" config:"required,nonempty"` + + // MasterKeys holds encryption keys for the vault. The first key is used + // for encryption; additional keys are retained for backwards-compatible + // decryption. If multiple keys are provided, vault will start a rekey + // process to migrate all secrets to the new key. + MasterKeys []string `toml:"master_keys" config:"required,nonempty"` + + // S3 configures the S3-compatible storage backend. See [S3Config]. + S3 S3Config `toml:"s3"` + + // Otel configures OpenTelemetry export. See [config.OtelConfig]. + Otel config.OtelConfig `toml:"otel"` + + // Logging configures log sampling. See [config.LoggingConfig]. + Logging config.LoggingConfig `toml:"logging"` +} +// Validate implements [config.Validator] so that [config.Load] calls it +// automatically after tag-level validation. All constraints are expressed +// through struct tags, so this method has nothing additional to check. +func (c *Config) Validate() error { + return nil } diff --git a/svc/vault/run.go b/svc/vault/run.go index f363735a8c..5dbff37b8c 100644 --- a/svc/vault/run.go +++ b/svc/vault/run.go @@ -23,18 +23,18 @@ 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, }) var shutdownGrafana func(context.Context) error - if cfg.OtelEnabled { + if cfg.Otel.Enabled { shutdownGrafana, err = otel.InitGrafana(ctx, otel.Config{ Application: "vault", Version: version.Version, InstanceID: cfg.InstanceID, CloudRegion: cfg.Region, - TraceSampleRate: cfg.OtelTraceSamplingRate, + TraceSampleRate: cfg.Otel.TraceSamplingRate, }) if err != nil { return fmt.Errorf("unable to init grafana: %w", err) @@ -51,10 +51,10 @@ func Run(ctx context.Context, cfg Config) error { r.RegisterHealth(mux) s3, err := storage.NewS3(storage.S3Config{ - S3URL: cfg.S3URL, - S3Bucket: cfg.S3Bucket, - S3AccessKeyID: cfg.S3AccessKeyID, - S3AccessKeySecret: cfg.S3AccessKeySecret, + S3URL: cfg.S3.URL, + S3Bucket: cfg.S3.Bucket, + S3AccessKeyID: cfg.S3.AccessKeyID, + S3AccessKeySecret: cfg.S3.AccessKeySecret, }) if err != nil { return fmt.Errorf("failed to create s3 storage: %w", err) diff --git a/web/apps/engineering/content/docs/architecture/services/api/config.mdx b/web/apps/engineering/content/docs/architecture/services/api/config.mdx index a58c654b3b..b6e342a3c8 100644 --- a/web/apps/engineering/content/docs/architecture/services/api/config.mdx +++ b/web/apps/engineering/content/docs/architecture/services/api/config.mdx @@ -1,376 +1,279 @@ --- -title: API +title: API Configuration --- -import { Step, Steps } from 'fumadocs-ui/components/steps'; -import { TypeTable } from 'fumadocs-ui/components/type-table'; import {Property} from "fumadocs-openapi/ui" +{/* Auto-generated from Go source. Do not edit manually. */} +{/* Run: go run ./cmd/generate-config-docs --pkg=./svc/ --type=Config */} - - This document only covers v2 of the Unkey API. The v1 API on Cloudflare Workers is deprecated and will be removed in the future. It was too hard to selfhost anyways. - +This page documents all configuration options available for the API service. +Configuration is loaded from a TOML file. Environment variables are expanded +using `${VAR}` or `${VAR:-default}` syntax before parsing. -Our API runs on AWS containers, in multiple regions behind a global load balancer to ensure high availability and low latency. +## General -The source code is available on [GitHub](https://github.com/unkeyed/unkey/tree/main/go/cmd/api). -## Quickstart + + InstanceID identifies this particular API server instance. Used in log +attribution, Kafka consumer group membership, and cache invalidation +messages so that a node can ignore its own broadcasts. -To get started, you need [go1.24+](https://go.dev/dl/) installed on your machine. - - + - - ### Clone the repository: + + Platform identifies the cloud platform where this node runs +(e.g. "aws", "gcp", "hetzner", "kubernetes"). Appears in structured +logs and metrics labels for filtering by infrastructure. -```bash -git clone git@github.com:unkeyed/unkey.git -cd unkey/go -``` - + - - ### Build the binary: + + Image is the container image identifier (e.g. "unkey/api:v1.2.3"). +Logged at startup for correlating deployments with behavior changes. -```bash -go build -o unkey . -``` - + - - ### Run the binary: + + HttpPort is the TCP port the API server binds to. Ignored when Listener +is set, which is the case in test harnesses that use ephemeral ports. -```bash -unkey api --database-primary="mysql://unkey:password@tcp(localhost:3306)/unkey?parseTime=true" -``` + **Constraints:** min: 1, max: 65535 -You should now be able to access the API at + -```bash -$ curl http://localhost:7070/v2/liveness -{"message":"we're cooking"}% -``` + + Region is the geographic region identifier (e.g. "us-east-1", "eu-west-1"). +Included in structured logs and used by the key service when recording +which region served a verification request. -By default, the API uses HTTP. To enable HTTPS with TLS, provide certificate and key files using the `--tls-cert-file` and `--tls-key-file` flags: + -```bash -unkey api \ - --database-primary="mysql://unkey:password@tcp(localhost:3306)/unkey?parseTime=true" \ - --tls-cert-file="/path/to/server.crt" \ - --tls-key-file="/path/to/server.key" -``` + + RedisURL is the connection string for the Redis instance backing +distributed rate limiting counters and usage tracking. +Example: "redis://redis:6379" -Then access it with HTTPS: + -```bash -$ curl https://localhost:7070/v2/liveness -{"message":"we're cooking"}% -``` - + + TestMode relaxes certain security checks and trusts client-supplied +headers that would normally be rejected. This exists for integration +tests that need to inject specific request metadata. +Do not enable in production. + + + PrometheusPort starts a Prometheus /metrics HTTP endpoint on the +specified port. Set to 0 (the default) to disable the endpoint entirely. - + -## Configuration + + MaxRequestBodySize caps incoming request bodies at this many bytes. +The zen server rejects requests exceeding this limit with a 413 status. +Set to 0 or negative to disable the limit. Defaults to 10 MiB. + -You can configure the Unkey API using command-line flags or environment variables. For each flag shown below, there's an equivalent environment variable. -For example, `--http-port=8080` can also be set using the environment variable `UNKEY_HTTP_PORT=8080`. +## Database -### Basic Configuration +DatabaseConfig holds connection strings for the primary MySQL database and an +optional read-replica. The primary is required for all deployments; the replica +reduces read load on the primary when set. -These options control the fundamental behavior of the API server. + + Primary is the MySQL DSN for the read-write database. This is the only +required field in the entire configuration because the API server cannot +function without a database. +Example: "user:pass@tcp(host:3306)/unkey?parseTime=true&interpolateParams=true" - - Identifies the cloud platform where this node is running. This information is primarily used for logging, metrics, and debugging purposes. + - **Environment variable:** `UNKEY_PLATFORM` + + ReadonlyReplica is an optional MySQL DSN for a read-replica. When set, +read queries are routed here to reduce load on the primary. The connection +string format is identical to Primary. - **Examples:** - - `--platform=aws` - When running on Amazon Web Services - - `--platform=gcp` - When running on Google Cloud Platform - - `--platform=hetzner` - When running on Hetzner Cloud - - `--platform=docker` - When running in Docker (e.g., local or Docker Compose) - - Container image identifier for this node. This information is used for logging, metrics, and helps with tracking which version of the application is running. - **Environment variable:** `UNKEY_IMAGE` +## ClickHouse - **Examples:** - - `--image=ghcr.io/unkeyed/unkey:latest` - Latest image from GitHub Container Registry - - `--image=ghcr.io/unkeyed/unkey:v1.2.3` - Specific version of the image - +ClickHouseConfig configures connections to ClickHouse for analytics storage. +All fields are optional; when URL is empty, a no-op analytics backend is used. - - Port for the API server to listen on for HTTP or HTTPS connections. When TLS is configured (using --tls-cert-file and --tls-key-file), the server will use HTTPS on this port. Otherwise, it will use HTTP. This port must be accessible by all clients that will interact with the API. In containerized environments, ensure this port is properly exposed. - **Examples:** - - `--http-port=7070` - Default port for HTTP - - `--http-port=443` - Standard HTTPS port (when using TLS) - - `--http-port=8443` - Common alternative HTTPS port (when using TLS) + + URL is the ClickHouse connection string for the shared analytics cluster. +When empty, analytics writes are silently discarded. +Example: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" + - - Enable test mode. This option is designed for testing environments and should NOT be used in production. When enabled, the server may trust client inputs blindly, potentially bypassing security checks. + + AnalyticsURL is the base URL for workspace-specific analytics connections. +Unlike URL, this endpoint receives per-workspace credentials injected at +connection time by the analytics service. Only used when both this field +and a [VaultConfig] are configured. +Example: "http://clickhouse:8123/default" + + - The server logs a warning when started with this flag enabled. + + ProxyToken is the bearer token for authenticating against ClickHouse proxy +endpoints exposed by the API server itself. - **Examples:** - - `--test-mode=true` - Enable test mode for testing environments - - `--test-mode=false` - Normal operation mode (default, suitable for production) - - Geographic region identifier where this node is deployed. Used for logging, metrics categorization, and can affect routing decisions in multi-region setups. - **Examples:** - - `--region=us-east-1` - AWS US East (N. Virginia) - - `--region=eu-west-1` - AWS Europe (Ireland) - - `--region=us-central1` - GCP US Central - - `--region=dev-local` - For local development environments +## Otel + +OtelConfig controls OpenTelemetry tracing and metrics export. When disabled, +no collector connection is established and no spans are recorded. + + + + Enabled activates OpenTelemetry tracing and metrics export to the +collector endpoint. Defaults to false. + - - Unique identifier for this instance. This identifier is used in logs, metrics, and for identifying this specific instance of the API server. If not provided, a random ID with a unique prefix will be auto-generated. + + TraceSamplingRate is the probability (0.0–1.0) that any given trace is +sampled. Lower values reduce overhead in high-throughput deployments. +Only meaningful when Enabled is true. - For persistent instances, setting a consistent ID can help with log correlation and tracking instance-specific issues over time. + **Constraints:** min: 0, max: 1 - **Examples:** - - `--instance-id=api-prod-1` - First production API instance - - `--instance-id=api-us-east-001` - API instance in US East region -## Database Configuration -The Unkey API requires a MySQL database for storing keys and configuration. For global deployments, a read replica endpoint can be configured to offload read operations. +## TLS Files + +TLSFiles holds filesystem paths to a TLS certificate and private key. +Both fields must be set together to enable HTTPS; setting only one is a +validation error. The certificate and key are loaded at startup and used +to construct a [tls.Config] stored in [Config.TLSConfig]. - - Primary database connection string for read and write operations. This MySQL database stores all persistent data including API keys, workspaces, and configuration. It is required for all deployments. - For production use, ensure the database has proper backup procedures in place. Unkey is using [PlanetScale](https://planetscale.com/) + + CertFile is the filesystem path to a PEM-encoded TLS certificate. - **Examples:** - - `--database-primary=root:password@localhost:3306/unkey?parseTime=true` - Local MySQL for development - - `--database-primary=nx...4c:pscale_pw_...va@tcp(aws.connect.psdb.cloud)/unkey?tls=true&interpolateParams=true&parseTime=true` - PlanetScale connection - - Optional read-replica database connection string for read operations. When provided, most read operations will be directed to this read replica, reducing load on the primary database and latency for users. + + KeyFile is the filesystem path to a PEM-encoded TLS private key. - This is recommended for high-traffic deployments to improve performance and scalability. The read replica must be a valid MySQL read replica of the primary database. + - Unkey is using [PlanetScales](https://planetscale.com/) global read replica endpoint. - **Examples:** - - `--database-replica=root:password@localhost:3306/unkey?parseTime=true` - Local MySQL for development - - `--database-replica=nx...4c:pscale_pw_...va@tcp(aws.connect.psdb.cloud)/unkey?tls=true&interpolateParams=true&parseTime=true` - PlanetScale connection - +## Vault -## Analytics & Monitoring +VaultConfig configures the connection to the remote vault service used for +encrypting and decrypting sensitive data like API key hashes. When URL is +empty, vault-dependent features (like workspace analytics credentials) are +disabled. -These options configure analytics storage and observability for the Unkey API. - - ClickHouse database connection string for analytics. ClickHouse is used for storing high-volume event data like API key validations, http request logs and historically aggregated analytics. + + URL is the vault service endpoint. +Example: "http://vault:8060" - This is optional but highly recommended for production environments. If not provided, analytical capabilities will be omitted but core key validation will still function. + + + Token is the bearer token used to authenticate with the vault service. - **Examples:** - - `--clickhouse-url=clickhouse://localhost:9000/unkey` - - `--clickhouse-url=clickhouse://user:password@clickhouse.example.com:9000/unkey` - - `--clickhouse-url=clickhouse://default:password@clickhouse.default.svc.cluster.local:9000/unkey?secure=true` - - Enable OpenTelemetry. The Unkey API will collect and export telemetry data (metrics, traces, and logs) using the OpenTelemetry protocol. - When this flag is set to true, the following standard OpenTelemetry environment variables are used to configure the exporter: +## Kafka - - `OTEL_EXPORTER_OTLP_ENDPOINT`: The URL of your OpenTelemetry collector - - `OTEL_EXPORTER_OTLP_PROTOCOL`: The protocol to use (http/protobuf or grpc) - - `OTEL_EXPORTER_OTLP_HEADERS`: Headers for authentication (e.g., "authorization=Bearer \") +KafkaConfig configures the Kafka connection used for distributed cache +invalidation across API server instances. When Brokers is empty, cache +invalidation is local-only and a no-op topic is used. - Using these standard variables ensures compatibility with OpenTelemetry documentation and tools. For detailed configuration information, see the [official OpenTelemetry documentation](https://grafana.com/docs/grafana-cloud/send-data/otlp/send-data-otlp/). - **Examples:** + + Brokers is the list of Kafka broker addresses. When empty, distributed +cache invalidation is disabled and each node operates independently. +Example: ["kafka-0:9092", "kafka-1:9092"] - ```bash - # Enable OpenTelemetry - export UNKEY_OTEL=true - export OTEL_EXPORTER_OTLP_ENDPOINT="https://otlp-sentinel-prod-us-east-0.grafana.net/otlp" - export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" - export OTEL_EXPORTER_OTLP_HEADERS="authorization=Basic ..." + + + + CacheInvalidationTopic is the Kafka topic name for broadcasting cache +invalidation events between API nodes. - # Or as command-line flags - unkey api --otel=true" - ``` - - Sets the sampling rate for OpenTelemetry traces as a decimal value between 0.0 and 1.0. This controls what percentage of traces will be collected and exported, helping to balance observability needs with performance and cost considerations. - - 0.0 means no traces are sampled (0%) - - 0.25 means 25% of traces are sampled (default) - - 1.0 means all traces are sampled (100%) +## Ctrl - Lower sampling rates reduce overhead and storage costs but provide less visibility. Higher rates give more comprehensive data but increase resource usage and costs. +CtrlConfig configures the connection to the CTRL service, which manages +deployments and rolling updates across the cluster. - This setting only takes effect when OpenTelemetry is enabled with `--otel=true`. - **Examples:** - - `--otel-trace-sampling-rate=0.1` - Sample 10% of traces - - `--otel-trace-sampling-rate=0.25` - Sample 25% of traces (default) - - `--otel-trace-sampling-rate=1.0` - Sample all traces + + URL is the CTRL service endpoint. +Example: "http://ctrl-api:7091" - **Environment variable:** `UNKEY_OTEL_TRACE_SAMPLING_RATE` - - Port for exposing Prometheus metrics. When set to a value greater than 0, the API server will expose a `/metrics` endpoint on the specified port for scraping by Prometheus. Setting this to 0 disables the Prometheus metrics endpoint. + + Token is the bearer token used to authenticate with the CTRL service. + + - This is useful for monitoring the API server's performance and health in production environments. The metrics include information about HTTP requests, database operations, cache performance, and more. - **Examples:** - - `--prometheus-port=0` - Disable Prometheus metrics endpoint (default) - - `--prometheus-port=9090` - Expose metrics on port 9090 - - `--prometheus-port=9100` - Standard port used by node_exporter +## Pprof - **Environment variable:** `UNKEY_PROMETHEUS_PORT` - +PprofConfig controls the Go pprof profiling endpoints served at /debug/pprof/*. +When disabled, the endpoints are not registered on the server mux. - - Enable ANSI color codes in log output. When enabled, log output will include ANSI color escape sequences to highlight different log levels, timestamps, and other components of the log messages. - This is useful for local development and debugging but may need to be disabled in production environments where logs are collected by systems that may not properly handle ANSI escape sequences. + + Enabled activates pprof profiling endpoints. Defaults to false. - **Examples:** - - `--color=true` - Enable colored logs (default) - - `--color=false` - Disable colored logs (for environments that don't handle ANSI colors well) -## Redis Configuration + + Username is the Basic Auth username for pprof endpoints. When both +Username and Password are empty, pprof endpoints are served without +authentication — only appropriate in development environments. - - Redis connection string for rate-limiting and distributed counters. Redis is used to maintain counters for rate limiting and other features that require distributed state. + - While not strictly required, Redis is recommended for production deployments, especially when running multiple instances of the API server, to ensure consistent rate limiting. + + Password is the Basic Auth password for pprof endpoints. - **Examples:** - - `--redis-url=redis://localhost:6379/0` - - `--redis-url=redis://user:password@redis.example.com:6379/0` - - `--redis-url=redis://user:password@redis-master.default.svc.cluster.local:6379/0?tls=true` -## TLS Configuration -These options allow you to enable HTTPS by providing TLS certificate and key files. Both flags must be provided together to enable HTTPS. +## Logging + +LoggingConfig controls log sampling behavior. The sampler reduces log volume +in production while ensuring slow requests are always captured. Events +faster than SlowThreshold are emitted with probability SampleRate; events +at or above the threshold are always emitted. + - - Path to the TLS certificate file in PEM format. This certificate will be used for securing HTTPS connections to the API server. - - For production use, this should be a certificate signed by a trusted certificate authority (CA). For development or internal use, a self-signed certificate may be sufficient. + + SampleRate is the baseline probability (0.0–1.0) of emitting a log event +that completes faster than SlowThreshold. Set to 1.0 to log everything. - This flag must be used together with `--tls-key-file`. If either is provided without the other, the server will return an error at startup. + **Constraints:** min: 0, max: 1 - **Examples:** - - `--tls-cert-file=/path/to/server.crt` - Path to certificate file - - `--tls-cert-file=/etc/ssl/certs/unkey-api.crt` - Standard location in Linux systems - - Path to the TLS private key file in PEM format. This key must correspond to the certificate provided in `--tls-cert-file`. - - The private key file should be kept secure with restricted permissions (0600 recommended). Never commit private keys to source control. + + SlowThreshold is the duration above which a request is considered slow +and always logged regardless of SampleRate. Uses Go duration syntax +(e.g. "1s", "500ms", "2m30s"). - **Examples:** - - `--tls-key-file=/path/to/server.key` - Path to key file - - `--tls-key-file=/etc/ssl/private/unkey-api.key` - Standard location in Linux systems -## Deployment Examples - -### Single-Node (HTTP) - -```bash -unkey api \ - --database-primary="mysql://root:password@localhost:3306/unkey?parseTime=true" \ - --color=true \ - --http-port=8080 \ - --region=dev-local -``` - -### Single-Node (HTTPS) - -```bash -unkey api \ - --database-primary="mysql://root:password@localhost:3306/unkey?parseTime=true" \ - --color=true \ - --http-port=8443 \ - --tls-cert-file="/path/to/server.crt" \ - --tls-key-file="/path/to/server.key" \ - --region=dev-local -``` - -### Docker Compose Setup - -```yaml -services: - api: - deploy: - replicas: 3 - endpoint_mode: vip - command: ["api"] - image: ghcr.io/unkeyed/unkey:latest - depends_on: - - mysql - - redis - - clickhouse - environment: - UNKEY_HTTP_PORT: 7070 - UNKEY_PLATFORM: "docker" - UNKEY_IMAGE: "ghcr.io/unkeyed/unkey:latest" - UNKEY_REDIS_URL: "redis://redis:6379" - UNKEY_DATABASE_PRIMARY_DSN: "mysql://unkey:password@tcp(mysql:3900)/unkey?parseTime=true" - UNKEY_CLICKHOUSE_URL: "clickhouse://default:password@clickhouse:9000" - UNKEY_PROMETHEUS_PORT: 9090 - # Uncomment for HTTPS: - # UNKEY_TLS_CERT_FILE: "/etc/ssl/certs/unkey-api.crt" - # UNKEY_TLS_KEY_FILE: "/etc/ssl/private/unkey-api.key" -``` - - -### AWS ECS Production Example - -```bash -unkey api \ - --platform="aws" \ - --region="us-east-1" \ - --image="ghcr.io/unkeyed/unkey:latest" \ - --redis-url="redis://user:password@redis.example.com:6379" \ - --database-primary="mysql://user:password@primary.mysql.example.com:3306/unkey?parseTime=true" \ - --database-readonly-replica="mysql://readonly:password@replica.mysql.example.com:3306/unkey?parseTime=true" \ - --clickhouse-url="clickhouse://user:password@clickhouse.example.com:9000/unkey" \ - --otel=true \ - --prometheus-port=9090 -``` - -### Production with HTTPS - -```bash -unkey api \ - --platform="aws" \ - --region="us-east-1" \ - --image="ghcr.io/unkeyed/unkey:latest" \ - --database-primary="mysql://user:password@primary.mysql.example.com:3306/unkey?parseTime=true" \ - --redis-url="redis://user:password@redis.example.com:6379" \ - --tls-cert-file="/etc/ssl/certs/unkey-api.crt" \ - --tls-key-file="/etc/ssl/private/unkey-api.key" \ - --http-port=443 -```