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/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/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/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/pkg/db/deployment_topology_update_desired_status.sql_generated.go b/pkg/db/deployment_topology_update_desired_status.sql_generated.go index f172fd2363..136f832cc1 100644 --- a/pkg/db/deployment_topology_update_desired_status.sql_generated.go +++ b/pkg/db/deployment_topology_update_desired_status.sql_generated.go @@ -31,6 +31,12 @@ type UpdateDeploymentTopologyDesiredStatusParams struct { // SET desired_status = ?, version = ?, updated_at = ? // WHERE deployment_id = ? AND region = ? func (q *Queries) UpdateDeploymentTopologyDesiredStatus(ctx context.Context, db DBTX, arg UpdateDeploymentTopologyDesiredStatusParams) error { - _, err := db.ExecContext(ctx, updateDeploymentTopologyDesiredStatus, arg.DesiredStatus, arg.Version, arg.UpdatedAt, arg.DeploymentID, arg.Region) + _, err := db.ExecContext(ctx, updateDeploymentTopologyDesiredStatus, + arg.DesiredStatus, + arg.Version, + arg.UpdatedAt, + arg.DeploymentID, + arg.Region, + ) return err } diff --git a/pkg/db/querier_generated.go b/pkg/db/querier_generated.go index 310908c977..0ec1ce628b 100644 --- a/pkg/db/querier_generated.go +++ b/pkg/db/querier_generated.go @@ -2412,13 +2412,6 @@ type Querier interface { // SET desired_state = ?, updated_at = ? // WHERE id = ? UpdateDeploymentDesiredState(ctx context.Context, db DBTX, arg UpdateDeploymentDesiredStateParams) error - //UpdateDeploymentTopologyDesiredStatus updates the desired_status and version of a topology entry. - // A new version is required so that WatchDeployments picks up the change. - // - // UPDATE `deployment_topology` - // SET desired_status = ?, version = ?, updated_at = ? - // WHERE deployment_id = ? AND region = ? - UpdateDeploymentTopologyDesiredStatus(ctx context.Context, db DBTX, arg UpdateDeploymentTopologyDesiredStatusParams) error //UpdateDeploymentImage // // UPDATE deployments @@ -2437,6 +2430,13 @@ type Querier interface { // SET status = ?, updated_at = ? // WHERE id = ? UpdateDeploymentStatus(ctx context.Context, db DBTX, arg UpdateDeploymentStatusParams) error + // UpdateDeploymentTopologyDesiredStatus updates the desired_status and version of a topology entry. + // A new version is required so that WatchDeployments picks up the change. + // + // UPDATE `deployment_topology` + // SET desired_status = ?, version = ?, updated_at = ? + // WHERE deployment_id = ? AND region = ? + UpdateDeploymentTopologyDesiredStatus(ctx context.Context, db DBTX, arg UpdateDeploymentTopologyDesiredStatusParams) error //UpdateFrontlineRouteDeploymentId // // UPDATE frontline_routes diff --git a/svc/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)