Refactor entrypoint

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-05 15:39:50 +01:00
parent fea53b589a
commit 63bf983e59
24 changed files with 1442 additions and 361 deletions

331
pkg/bootstrap/builder.go Normal file
View File

@@ -0,0 +1,331 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package bootstrap
import (
"fmt"
"os"
"strconv"
"strings"
"go.probo.inc/probo/pkg/probod"
)
// EnvGetter is a function that retrieves environment variables.
// This allows for easy testing by injecting a mock implementation.
type EnvGetter func(key string) string
// Builder creates a Config from environment variables.
type Builder struct {
getEnv EnvGetter
samlCertificate string
samlPrivateKey string
}
// NewBuilder creates a new Builder with the given environment getter.
// If getEnv is nil, os.Getenv is used.
func NewBuilder(getEnv EnvGetter) *Builder {
if getEnv == nil {
getEnv = os.Getenv
}
return &Builder{getEnv: getEnv}
}
// SetSAMLCredentials sets pre-generated SAML certificate and private key.
// If not set, they will be generated automatically if not provided via environment.
func (b *Builder) SetSAMLCredentials(certificate, privateKey string) {
b.samlCertificate = certificate
b.samlPrivateKey = privateKey
}
// Build creates a FullConfig from environment variables.
// Returns an error if required environment variables are missing.
func (b *Builder) Build() (*probod.FullConfig, error) {
if err := b.validateRequired(); err != nil {
return nil, err
}
samlCert, samlKey, err := b.getSAMLCredentials()
if err != nil {
return nil, fmt.Errorf("failed to get SAML credentials: %w", err)
}
pgCACertBundle := b.getPgCACertBundle()
cfg := &probod.FullConfig{
Unit: probod.UnitConfig{
Metrics: probod.MetricsConfig{
Addr: b.getEnvOrDefault("METRICS_ADDR", "localhost:8081"),
},
Tracing: probod.TracingConfig{
Addr: b.getEnvOrDefault("TRACING_ADDR", "localhost:4317"),
MaxBatchSize: b.getEnvIntOrDefault("TRACING_MAX_BATCH_SIZE", 512),
BatchTimeout: b.getEnvIntOrDefault("TRACING_BATCH_TIMEOUT", 5),
ExportTimeout: b.getEnvIntOrDefault("TRACING_EXPORT_TIMEOUT", 30),
MaxQueueSize: b.getEnvIntOrDefault("TRACING_MAX_QUEUE_SIZE", 2048),
},
},
Probod: probod.Config{
BaseURL: b.getEnvOrDefault("PROBOD_BASE_URL", "http://localhost:8080"),
EncryptionKey: b.getEnv("PROBOD_ENCRYPTION_KEY"),
ChromeDPAddr: b.getEnvOrDefault("CHROME_DP_ADDR", "localhost:9222"),
Api: probod.APIConfig{
Addr: b.getEnvOrDefault("API_ADDR", ":8080"),
ProxyProtocol: probod.ProxyProtocolConfig{
TrustedProxies: b.parseOriginsList(b.getEnv("API_PROXY_PROTOCOL_TRUSTED_PROXIES")),
},
Cors: probod.CorsConfig{
AllowedOrigins: b.parseOriginsList(b.getEnvOrDefault("API_CORS_ALLOWED_ORIGINS", "http://localhost:8080")),
},
ExtraHeaderFields: make(map[string]string),
},
Pg: probod.PgConfig{
Addr: b.getEnvOrDefault("PG_ADDR", "localhost:5432"),
Username: b.getEnvOrDefault("PG_USERNAME", "postgres"),
Password: b.getEnvOrDefault("PG_PASSWORD", "postgres"),
Database: b.getEnvOrDefault("PG_DATABASE", "probod"),
PoolSize: int32(b.getEnvIntOrDefault("PG_POOL_SIZE", 100)),
CACertBundle: pgCACertBundle,
Debug: b.getEnvBoolOrDefault("PG_DEBUG", false),
},
Auth: probod.AuthConfig{
DisableSignup: b.getEnvBoolOrDefault("AUTH_DISABLE_SIGNUP", false),
InvitationConfirmationTokenValidity: b.getEnvIntOrDefault("AUTH_INVITATION_TOKEN_VALIDITY", 3600),
PasswordResetTokenValidity: b.getEnvIntOrDefault("AUTH_PASSWORD_RESET_TOKEN_VALIDITY", 3600),
MagicLinkTokenValidity: b.getEnvIntOrDefault("AUTH_MAGIC_LINK_TOKEN_VALIDITY", 900),
Cookie: probod.CookieConfig{
Name: b.getEnvOrDefault("AUTH_COOKIE_NAME", "SSID"),
Domain: b.getEnvOrDefault("AUTH_COOKIE_DOMAIN", "localhost"),
Secret: b.getEnv("AUTH_COOKIE_SECRET"),
Duration: b.getEnvIntOrDefault("AUTH_COOKIE_DURATION", 24),
Secure: b.getEnvBoolOrDefault("AUTH_COOKIE_SECURE", true),
},
Password: probod.PasswordConfig{
Pepper: b.getEnv("AUTH_PASSWORD_PEPPER"),
Iterations: b.getEnvIntOrDefault("AUTH_PASSWORD_ITERATIONS", 1000000),
},
SAML: probod.SAMLConfig{
SessionDuration: b.getEnvIntOrDefault("SAML_SESSION_DURATION", 604800),
CleanupIntervalSeconds: b.getEnvIntOrDefault("SAML_CLEANUP_INTERVAL_SECONDS", 0),
Certificate: samlCert,
PrivateKey: samlKey,
DomainVerificationIntervalSeconds: b.getEnvIntOrDefault("SAML_DOMAIN_VERIFICATION_INTERVAL_SECONDS", 60),
DomainVerificationResolverAddr: b.getEnvOrDefault("SAML_DOMAIN_VERIFICATION_RESOLVER_ADDR", "8.8.8.8:53"),
},
},
TrustCenter: probod.TrustCenterConfig{
HTTPAddr: b.getEnvOrDefault("TRUST_CENTER_HTTP_ADDR", ":80"),
HTTPSAddr: b.getEnvOrDefault("TRUST_CENTER_HTTPS_ADDR", ":443"),
ProxyProtocol: probod.ProxyProtocolConfig{
TrustedProxies: b.parseOriginsList(b.getEnv("TRUST_CENTER_PROXY_PROTOCOL_TRUSTED_PROXIES")),
},
},
AWS: probod.AWSConfig{
Region: b.getEnvOrDefault("AWS_REGION", "us-east-1"),
Bucket: b.getEnvOrDefault("AWS_BUCKET", "probod"),
AccessKeyID: b.getEnv("AWS_ACCESS_KEY_ID"),
SecretAccessKey: b.getEnv("AWS_SECRET_ACCESS_KEY"),
Endpoint: b.getEnv("AWS_ENDPOINT"),
UsePathStyle: b.getEnvBoolOrDefault("AWS_USE_PATH_STYLE", false),
},
Notifications: probod.NotificationsConfig{
Mailer: probod.MailerConfig{
SenderName: b.getEnvOrDefault("MAILER_SENDER_NAME", "Probo"),
SenderEmail: b.getEnvOrDefault("MAILER_SENDER_EMAIL", "no-reply@notification.getprobo.com"),
MailerInterval: b.getEnvIntOrDefault("MAILER_INTERVAL", 60),
SMTP: probod.SMTPConfig{
Addr: b.getEnvOrDefault("SMTP_ADDR", "localhost:1025"),
User: b.getEnv("SMTP_USER"),
Password: b.getEnv("SMTP_PASSWORD"),
TLSRequired: b.getEnvBoolOrDefault("SMTP_TLS_REQUIRED", false),
},
},
Slack: probod.SlackConfig{
SenderInterval: b.getEnvIntOrDefault("SLACK_SENDER_INTERVAL", 60),
SigningSecret: b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"),
},
Webhook: probod.WebhookConfig{
SenderInterval: b.getEnvIntOrDefault("WEBHOOK_SENDER_INTERVAL", 5),
CacheTTL: b.getEnvIntOrDefault("WEBHOOK_CACHE_TTL", 86400),
},
},
OpenAI: probod.OpenAIConfig{
APIKey: b.getEnv("OPENAI_API_KEY"),
Temperature: b.getEnvFloatOrDefault("OPENAI_TEMPERATURE", 0.1),
ModelName: b.getEnvOrDefault("OPENAI_MODEL_NAME", "gpt-4o"),
},
CustomDomains: probod.CustomDomainsConfig{
RenewalInterval: b.getEnvIntOrDefault("CUSTOM_DOMAINS_RENEWAL_INTERVAL", 3600),
ProvisionInterval: b.getEnvIntOrDefault("CUSTOM_DOMAINS_PROVISION_INTERVAL", 30),
CnameTarget: b.getEnvOrDefault("CUSTOM_DOMAINS_CNAME_TARGET", "custom.getprobo.com"),
ResolverAddr: b.getEnvOrDefault("CUSTOM_DOMAINS_RESOLVER_ADDR", "8.8.8.8:53"),
ACME: probod.ACMEConfig{
Directory: b.getEnvOrDefault("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory"),
Email: b.getEnvOrDefault("ACME_EMAIL", "admin@getprobo.com"),
KeyType: b.getEnvOrDefault("ACME_KEY_TYPE", "EC256"),
RootCA: b.getEnv("ACME_ROOT_CA"),
AccountKey: b.getEnv("ACME_ACCOUNT_KEY"),
},
},
SCIMBridge: probod.SCIMBridgeConfig{
SyncInterval: b.getEnvIntOrDefault("SCIM_BRIDGE_SYNC_INTERVAL", 900),
PollInterval: b.getEnvIntOrDefault("SCIM_BRIDGE_POLL_INTERVAL", 30),
},
ESign: probod.ESignConfig{
TSAURL: b.getEnvOrDefault("ESIGN_TSA_URL", "http://timestamp.digicert.com"),
},
},
}
if slackClientID := b.getEnv("CONNECTOR_SLACK_CLIENT_ID"); slackClientID != "" {
cfg.Probod.Connectors = []probod.ConnectorConfig{
{
Provider: "SLACK",
Protocol: "oauth2",
RawConfig: probod.ConnectorConfigOAuth2{
ClientID: slackClientID,
ClientSecret: b.getEnv("CONNECTOR_SLACK_CLIENT_SECRET"),
RedirectURI: b.getEnv("CONNECTOR_SLACK_REDIRECT_URI"),
AuthURL: b.getEnvOrDefault("CONNECTOR_SLACK_AUTH_URL", "https://slack.com/oauth/v2/authorize"),
TokenURL: b.getEnvOrDefault("CONNECTOR_SLACK_TOKEN_URL", "https://slack.com/api/oauth.v2.access"),
Scopes: []string{"chat:write", "channels:join", "incoming-webhook"},
},
RawSettings: map[string]interface{}{
"signing-secret": b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"),
},
},
}
}
return cfg, nil
}
func (b *Builder) validateRequired() error {
var missing []string
required := []string{
"PROBOD_ENCRYPTION_KEY",
"AUTH_COOKIE_SECRET",
"AUTH_PASSWORD_PEPPER",
}
for _, key := range required {
if b.getEnv(key) == "" {
missing = append(missing, key)
}
}
if slackClientID := b.getEnv("CONNECTOR_SLACK_CLIENT_ID"); slackClientID != "" {
slackRequired := []string{
"CONNECTOR_SLACK_CLIENT_SECRET",
"CONNECTOR_SLACK_SIGNING_SECRET",
"CONNECTOR_SLACK_REDIRECT_URI",
}
for _, key := range slackRequired {
if b.getEnv(key) == "" {
missing = append(missing, key+" (required when CONNECTOR_SLACK_CLIENT_ID is set)")
}
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required environment variables:\n - %s", strings.Join(missing, "\n - "))
}
return nil
}
func (b *Builder) getSAMLCredentials() (cert, key string, err error) {
cert = b.samlCertificate
key = b.samlPrivateKey
if cert == "" {
cert = b.getEnv("SAML_CERTIFICATE")
}
if key == "" {
key = b.getEnv("SAML_PRIVATE_KEY")
}
if cert == "" || key == "" {
cert, key, err = GenerateSAMLCertificate()
if err != nil {
return "", "", err
}
}
return cert, key, nil
}
func (b *Builder) getPgCACertBundle() string {
if path := b.getEnv("PG_CA_BUNDLE_PATH"); path != "" {
data, err := os.ReadFile(path)
if err == nil {
return string(data)
}
}
return b.getEnv("PG_CA_BUNDLE")
}
func (b *Builder) getEnvOrDefault(key, defaultValue string) string {
if value := b.getEnv(key); value != "" {
return value
}
return defaultValue
}
func (b *Builder) getEnvIntOrDefault(key string, defaultValue int) int {
if value := b.getEnv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func (b *Builder) getEnvFloatOrDefault(key string, defaultValue float64) float64 {
if value := b.getEnv(key); value != "" {
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
return floatValue
}
}
return defaultValue
}
func (b *Builder) getEnvBoolOrDefault(key string, defaultValue bool) bool {
if value := b.getEnv(key); value != "" {
if boolValue, err := strconv.ParseBool(value); err == nil {
return boolValue
}
}
return defaultValue
}
func (b *Builder) parseOriginsList(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var result []string
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
part = strings.Trim(part, "\"")
if part != "" {
result = append(result, part)
}
}
return result
}

View File

@@ -0,0 +1,465 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package bootstrap
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/probod"
)
func mockEnv(env map[string]string) EnvGetter {
return func(key string) string {
return env[key]
}
}
func requiredEnv() map[string]string {
return map[string]string{
"PROBOD_ENCRYPTION_KEY": "test-encryption-key-32-bytes-long",
"AUTH_COOKIE_SECRET": "test-cookie-secret-32-bytes-long!",
"AUTH_PASSWORD_PEPPER": "test-password-pepper-32-bytes-lo",
}
}
func TestBuilder_Build_MissingRequiredEnvVars(t *testing.T) {
tests := []struct {
name string
env map[string]string
wantMissing []string
}{
{
name: "all missing",
env: map[string]string{},
wantMissing: []string{"PROBOD_ENCRYPTION_KEY", "AUTH_COOKIE_SECRET", "AUTH_PASSWORD_PEPPER"},
},
{
name: "missing encryption key",
env: map[string]string{
"AUTH_COOKIE_SECRET": "secret",
"AUTH_PASSWORD_PEPPER": "pepper",
},
wantMissing: []string{"PROBOD_ENCRYPTION_KEY"},
},
{
name: "missing cookie secret",
env: map[string]string{
"PROBOD_ENCRYPTION_KEY": "key",
"AUTH_PASSWORD_PEPPER": "pepper",
},
wantMissing: []string{"AUTH_COOKIE_SECRET"},
},
{
name: "slack connector missing required fields",
env: map[string]string{
"PROBOD_ENCRYPTION_KEY": "key",
"AUTH_COOKIE_SECRET": "secret",
"AUTH_PASSWORD_PEPPER": "pepper",
"CONNECTOR_SLACK_CLIENT_ID": "client-id",
},
wantMissing: []string{"CONNECTOR_SLACK_CLIENT_SECRET", "CONNECTOR_SLACK_SIGNING_SECRET", "CONNECTOR_SLACK_REDIRECT_URI"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := NewBuilder(mockEnv(tt.env))
_, err := b.Build()
require.Error(t, err)
for _, missing := range tt.wantMissing {
assert.Contains(t, err.Error(), missing)
}
})
}
}
func TestBuilder_Build_Defaults(t *testing.T) {
b := NewBuilder(mockEnv(requiredEnv()))
b.SetSAMLCredentials("test-cert", "test-key")
cfg, err := b.Build()
require.NoError(t, err)
// Unit config
assert.Equal(t, "localhost:8081", cfg.Unit.Metrics.Addr)
assert.Equal(t, "localhost:4317", cfg.Unit.Tracing.Addr)
assert.Equal(t, 512, cfg.Unit.Tracing.MaxBatchSize)
assert.Equal(t, 5, cfg.Unit.Tracing.BatchTimeout)
assert.Equal(t, 30, cfg.Unit.Tracing.ExportTimeout)
assert.Equal(t, 2048, cfg.Unit.Tracing.MaxQueueSize)
// Probod base config
assert.Equal(t, "http://localhost:8080", cfg.Probod.BaseURL)
assert.Equal(t, "localhost:9222", cfg.Probod.ChromeDPAddr)
// API config
assert.Equal(t, ":8080", cfg.Probod.Api.Addr)
assert.Nil(t, cfg.Probod.Api.ProxyProtocol.TrustedProxies)
assert.Equal(t, []string{"http://localhost:8080"}, cfg.Probod.Api.Cors.AllowedOrigins)
// PG config
assert.Equal(t, "localhost:5432", cfg.Probod.Pg.Addr)
assert.Equal(t, "postgres", cfg.Probod.Pg.Username)
assert.Equal(t, "postgres", cfg.Probod.Pg.Password)
assert.Equal(t, "probod", cfg.Probod.Pg.Database)
assert.Equal(t, int32(100), cfg.Probod.Pg.PoolSize)
assert.False(t, cfg.Probod.Pg.Debug)
// Auth config
assert.False(t, cfg.Probod.Auth.DisableSignup)
assert.Equal(t, 3600, cfg.Probod.Auth.InvitationConfirmationTokenValidity)
assert.Equal(t, 3600, cfg.Probod.Auth.PasswordResetTokenValidity)
assert.Equal(t, 900, cfg.Probod.Auth.MagicLinkTokenValidity)
assert.Equal(t, "SSID", cfg.Probod.Auth.Cookie.Name)
assert.Equal(t, "localhost", cfg.Probod.Auth.Cookie.Domain)
assert.Equal(t, 24, cfg.Probod.Auth.Cookie.Duration)
assert.True(t, cfg.Probod.Auth.Cookie.Secure)
assert.Equal(t, 1000000, cfg.Probod.Auth.Password.Iterations)
// SAML config
assert.Equal(t, 604800, cfg.Probod.Auth.SAML.SessionDuration)
assert.Equal(t, 0, cfg.Probod.Auth.SAML.CleanupIntervalSeconds)
assert.Equal(t, 60, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds)
assert.Equal(t, "8.8.8.8:53", cfg.Probod.Auth.SAML.DomainVerificationResolverAddr)
// Trust center config
assert.Equal(t, ":80", cfg.Probod.TrustCenter.HTTPAddr)
assert.Equal(t, ":443", cfg.Probod.TrustCenter.HTTPSAddr)
assert.Nil(t, cfg.Probod.TrustCenter.ProxyProtocol.TrustedProxies)
// AWS config
assert.Equal(t, "us-east-1", cfg.Probod.AWS.Region)
assert.Equal(t, "probod", cfg.Probod.AWS.Bucket)
assert.False(t, cfg.Probod.AWS.UsePathStyle)
// Notifications config
assert.Equal(t, "Probo", cfg.Probod.Notifications.Mailer.SenderName)
assert.Equal(t, "no-reply@notification.getprobo.com", cfg.Probod.Notifications.Mailer.SenderEmail)
assert.Equal(t, "localhost:1025", cfg.Probod.Notifications.Mailer.SMTP.Addr)
assert.False(t, cfg.Probod.Notifications.Mailer.SMTP.TLSRequired)
assert.Equal(t, 60, cfg.Probod.Notifications.Mailer.MailerInterval)
assert.Equal(t, 60, cfg.Probod.Notifications.Slack.SenderInterval)
assert.Empty(t, cfg.Probod.Notifications.Slack.SigningSecret)
assert.Equal(t, 5, cfg.Probod.Notifications.Webhook.SenderInterval)
assert.Equal(t, 86400, cfg.Probod.Notifications.Webhook.CacheTTL)
// OpenAI config
assert.Equal(t, 0.1, cfg.Probod.OpenAI.Temperature)
assert.Equal(t, "gpt-4o", cfg.Probod.OpenAI.ModelName)
// Custom domains config
assert.Equal(t, 3600, cfg.Probod.CustomDomains.RenewalInterval)
assert.Equal(t, 30, cfg.Probod.CustomDomains.ProvisionInterval)
assert.Equal(t, "custom.getprobo.com", cfg.Probod.CustomDomains.CnameTarget)
assert.Equal(t, "8.8.8.8:53", cfg.Probod.CustomDomains.ResolverAddr)
assert.Equal(t, "https://acme-v02.api.letsencrypt.org/directory", cfg.Probod.CustomDomains.ACME.Directory)
assert.Equal(t, "admin@getprobo.com", cfg.Probod.CustomDomains.ACME.Email)
assert.Equal(t, "EC256", cfg.Probod.CustomDomains.ACME.KeyType)
// SCIM bridge config
assert.Equal(t, 900, cfg.Probod.SCIMBridge.SyncInterval)
assert.Equal(t, 30, cfg.Probod.SCIMBridge.PollInterval)
// ESign config
assert.Equal(t, "http://timestamp.digicert.com", cfg.Probod.ESign.TSAURL)
// No connectors by default
assert.Empty(t, cfg.Probod.Connectors)
}
func TestBuilder_Build_CustomValues(t *testing.T) {
env := requiredEnv()
// Unit
env["METRICS_ADDR"] = "0.0.0.0:9090"
env["TRACING_ADDR"] = "jaeger:4317"
env["TRACING_MAX_BATCH_SIZE"] = "1024"
// Probod
env["PROBOD_BASE_URL"] = "https://app.example.com"
env["CHROME_DP_ADDR"] = "chrome:9222"
// API
env["API_ADDR"] = "0.0.0.0:8080"
env["API_CORS_ALLOWED_ORIGINS"] = "https://app.example.com,https://admin.example.com"
env["API_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.0.1,10.0.0.2"
// PG
env["PG_ADDR"] = "postgres.example.com:5432"
env["PG_USERNAME"] = "probo"
env["PG_PASSWORD"] = "secret123"
env["PG_DATABASE"] = "probo_prod"
env["PG_POOL_SIZE"] = "200"
env["PG_DEBUG"] = "true"
// Auth
env["AUTH_DISABLE_SIGNUP"] = "true"
env["AUTH_INVITATION_TOKEN_VALIDITY"] = "7200"
env["AUTH_PASSWORD_RESET_TOKEN_VALIDITY"] = "1800"
env["AUTH_MAGIC_LINK_TOKEN_VALIDITY"] = "600"
env["AUTH_COOKIE_DOMAIN"] = ".example.com"
env["AUTH_COOKIE_DURATION"] = "48"
// SAML
env["SAML_DOMAIN_VERIFICATION_INTERVAL_SECONDS"] = "120"
env["SAML_DOMAIN_VERIFICATION_RESOLVER_ADDR"] = "1.1.1.1:53"
// Trust center
env["TRUST_CENTER_HTTP_ADDR"] = ":8080"
env["TRUST_CENTER_HTTPS_ADDR"] = ":8443"
env["TRUST_CENTER_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.1.1,10.0.1.2"
// AWS
env["AWS_REGION"] = "eu-west-1"
env["AWS_BUCKET"] = "probo-files"
env["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE"
env["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
env["AWS_ENDPOINT"] = "https://s3.example.com"
env["AWS_USE_PATH_STYLE"] = "true"
// Notifications
env["WEBHOOK_SENDER_INTERVAL"] = "10"
env["WEBHOOK_CACHE_TTL"] = "3600"
env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret"
// OpenAI
env["OPENAI_API_KEY"] = "sk-test-key"
env["OPENAI_TEMPERATURE"] = "0.5"
env["OPENAI_MODEL_NAME"] = "gpt-4-turbo"
// Custom domains
env["CUSTOM_DOMAINS_RESOLVER_ADDR"] = "1.1.1.1:53"
env["ACME_ACCOUNT_KEY"] = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----"
// SCIM bridge
env["SCIM_BRIDGE_SYNC_INTERVAL"] = "1800"
env["SCIM_BRIDGE_POLL_INTERVAL"] = "60"
// ESign
env["ESIGN_TSA_URL"] = "http://custom.tsa.example.com"
b := NewBuilder(mockEnv(env))
b.SetSAMLCredentials("test-cert", "test-key")
cfg, err := b.Build()
require.NoError(t, err)
// Unit
assert.Equal(t, "0.0.0.0:9090", cfg.Unit.Metrics.Addr)
assert.Equal(t, "jaeger:4317", cfg.Unit.Tracing.Addr)
assert.Equal(t, 1024, cfg.Unit.Tracing.MaxBatchSize)
// Probod
assert.Equal(t, "https://app.example.com", cfg.Probod.BaseURL)
assert.Equal(t, "chrome:9222", cfg.Probod.ChromeDPAddr)
// API
assert.Equal(t, "0.0.0.0:8080", cfg.Probod.Api.Addr)
assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, cfg.Probod.Api.ProxyProtocol.TrustedProxies)
assert.Equal(t, []string{"https://app.example.com", "https://admin.example.com"}, cfg.Probod.Api.Cors.AllowedOrigins)
// PG
assert.Equal(t, "postgres.example.com:5432", cfg.Probod.Pg.Addr)
assert.Equal(t, "probo", cfg.Probod.Pg.Username)
assert.Equal(t, "secret123", cfg.Probod.Pg.Password)
assert.Equal(t, "probo_prod", cfg.Probod.Pg.Database)
assert.Equal(t, int32(200), cfg.Probod.Pg.PoolSize)
assert.True(t, cfg.Probod.Pg.Debug)
// Auth
assert.True(t, cfg.Probod.Auth.DisableSignup)
assert.Equal(t, 7200, cfg.Probod.Auth.InvitationConfirmationTokenValidity)
assert.Equal(t, 1800, cfg.Probod.Auth.PasswordResetTokenValidity)
assert.Equal(t, 600, cfg.Probod.Auth.MagicLinkTokenValidity)
assert.Equal(t, ".example.com", cfg.Probod.Auth.Cookie.Domain)
assert.Equal(t, 48, cfg.Probod.Auth.Cookie.Duration)
// SAML
assert.Equal(t, 120, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds)
assert.Equal(t, "1.1.1.1:53", cfg.Probod.Auth.SAML.DomainVerificationResolverAddr)
// Trust center
assert.Equal(t, ":8080", cfg.Probod.TrustCenter.HTTPAddr)
assert.Equal(t, ":8443", cfg.Probod.TrustCenter.HTTPSAddr)
assert.Equal(t, []string{"10.0.1.1", "10.0.1.2"}, cfg.Probod.TrustCenter.ProxyProtocol.TrustedProxies)
// AWS
assert.Equal(t, "eu-west-1", cfg.Probod.AWS.Region)
assert.Equal(t, "probo-files", cfg.Probod.AWS.Bucket)
assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", cfg.Probod.AWS.AccessKeyID)
assert.Equal(t, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", cfg.Probod.AWS.SecretAccessKey)
assert.Equal(t, "https://s3.example.com", cfg.Probod.AWS.Endpoint)
assert.True(t, cfg.Probod.AWS.UsePathStyle)
// Notifications
assert.Equal(t, "slack-signing-secret", cfg.Probod.Notifications.Slack.SigningSecret)
assert.Equal(t, 10, cfg.Probod.Notifications.Webhook.SenderInterval)
assert.Equal(t, 3600, cfg.Probod.Notifications.Webhook.CacheTTL)
// OpenAI
assert.Equal(t, "sk-test-key", cfg.Probod.OpenAI.APIKey)
assert.Equal(t, 0.5, cfg.Probod.OpenAI.Temperature)
assert.Equal(t, "gpt-4-turbo", cfg.Probod.OpenAI.ModelName)
// Custom domains
assert.Equal(t, "1.1.1.1:53", cfg.Probod.CustomDomains.ResolverAddr)
assert.Equal(t, "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----", cfg.Probod.CustomDomains.ACME.AccountKey)
// SCIM bridge
assert.Equal(t, 1800, cfg.Probod.SCIMBridge.SyncInterval)
assert.Equal(t, 60, cfg.Probod.SCIMBridge.PollInterval)
// ESign
assert.Equal(t, "http://custom.tsa.example.com", cfg.Probod.ESign.TSAURL)
}
func TestBuilder_Build_SlackConnector(t *testing.T) {
env := requiredEnv()
env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id"
env["CONNECTOR_SLACK_CLIENT_SECRET"] = "slack-client-secret"
env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret"
env["CONNECTOR_SLACK_REDIRECT_URI"] = "https://app.example.com/api/console/v1/connectors/complete"
b := NewBuilder(mockEnv(env))
b.SetSAMLCredentials("test-cert", "test-key")
cfg, err := b.Build()
require.NoError(t, err)
require.Len(t, cfg.Probod.Connectors, 1)
connector := cfg.Probod.Connectors[0]
assert.Equal(t, "SLACK", connector.Provider)
assert.Equal(t, "oauth2", string(connector.Protocol))
rawConfig := connector.RawConfig.(probod.ConnectorConfigOAuth2)
assert.Equal(t, "slack-client-id", rawConfig.ClientID)
assert.Equal(t, "slack-client-secret", rawConfig.ClientSecret)
assert.Equal(t, "https://app.example.com/api/console/v1/connectors/complete", rawConfig.RedirectURI)
assert.Equal(t, "https://slack.com/oauth/v2/authorize", rawConfig.AuthURL)
assert.Equal(t, "https://slack.com/api/oauth.v2.access", rawConfig.TokenURL)
assert.Equal(t, []string{"chat:write", "channels:join", "incoming-webhook"}, rawConfig.Scopes)
rawSettings := connector.RawSettings.(map[string]interface{})
assert.Equal(t, "slack-signing-secret", rawSettings["signing-secret"])
}
func TestBuilder_Build_SlackConnector_CustomURLs(t *testing.T) {
env := requiredEnv()
env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id"
env["CONNECTOR_SLACK_CLIENT_SECRET"] = "slack-client-secret"
env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret"
env["CONNECTOR_SLACK_REDIRECT_URI"] = "https://app.example.com/callback"
env["CONNECTOR_SLACK_AUTH_URL"] = "https://custom.slack.com/oauth/authorize"
env["CONNECTOR_SLACK_TOKEN_URL"] = "https://custom.slack.com/oauth/token"
b := NewBuilder(mockEnv(env))
b.SetSAMLCredentials("test-cert", "test-key")
cfg, err := b.Build()
require.NoError(t, err)
connector := cfg.Probod.Connectors[0]
rawConfig := connector.RawConfig.(probod.ConnectorConfigOAuth2)
assert.Equal(t, "https://custom.slack.com/oauth/authorize", rawConfig.AuthURL)
assert.Equal(t, "https://custom.slack.com/oauth/token", rawConfig.TokenURL)
}
func TestBuilder_Build_SAMLAutoGeneration(t *testing.T) {
b := NewBuilder(mockEnv(requiredEnv()))
cfg, err := b.Build()
require.NoError(t, err)
assert.Contains(t, cfg.Probod.Auth.SAML.Certificate, "-----BEGIN CERTIFICATE-----")
assert.Contains(t, cfg.Probod.Auth.SAML.Certificate, "-----END CERTIFICATE-----")
assert.Contains(t, cfg.Probod.Auth.SAML.PrivateKey, "-----BEGIN RSA PRIVATE KEY-----")
assert.Contains(t, cfg.Probod.Auth.SAML.PrivateKey, "-----END RSA PRIVATE KEY-----")
}
func TestBuilder_Build_SAMLFromEnv(t *testing.T) {
env := requiredEnv()
env["SAML_CERTIFICATE"] = "env-cert"
env["SAML_PRIVATE_KEY"] = "env-key"
b := NewBuilder(mockEnv(env))
cfg, err := b.Build()
require.NoError(t, err)
assert.Equal(t, "env-cert", cfg.Probod.Auth.SAML.Certificate)
assert.Equal(t, "env-key", cfg.Probod.Auth.SAML.PrivateKey)
}
func TestBuilder_Build_SAMLPreset(t *testing.T) {
b := NewBuilder(mockEnv(requiredEnv()))
b.SetSAMLCredentials("preset-cert", "preset-key")
cfg, err := b.Build()
require.NoError(t, err)
assert.Equal(t, "preset-cert", cfg.Probod.Auth.SAML.Certificate)
assert.Equal(t, "preset-key", cfg.Probod.Auth.SAML.PrivateKey)
}
func TestBuilder_Build_PgCABundleFromEnv(t *testing.T) {
env := requiredEnv()
env["PG_CA_BUNDLE"] = "test-ca-bundle-content"
b := NewBuilder(mockEnv(env))
b.SetSAMLCredentials("test-cert", "test-key")
cfg, err := b.Build()
require.NoError(t, err)
assert.Equal(t, "test-ca-bundle-content", cfg.Probod.Pg.CACertBundle)
}
func TestBuilder_Build_PgCABundleFromFile(t *testing.T) {
tmpDir := t.TempDir()
caFile := filepath.Join(tmpDir, "ca-bundle.pem")
err := os.WriteFile(caFile, []byte("ca-bundle-from-file"), 0644)
require.NoError(t, err)
env := requiredEnv()
env["PG_CA_BUNDLE_PATH"] = caFile
b := NewBuilder(mockEnv(env))
b.SetSAMLCredentials("test-cert", "test-key")
cfg, err := b.Build()
require.NoError(t, err)
assert.Equal(t, "ca-bundle-from-file", cfg.Probod.Pg.CACertBundle)
}
func TestBuilder_parseOriginsList(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "single origin",
input: "http://localhost:8080",
want: []string{"http://localhost:8080"},
},
{
name: "multiple origins",
input: "http://localhost:8080,https://example.com",
want: []string{"http://localhost:8080", "https://example.com"},
},
{
name: "quoted origins",
input: `"http://localhost:8080","https://example.com"`,
want: []string{"http://localhost:8080", "https://example.com"},
},
{
name: "with spaces",
input: "http://localhost:8080 , https://example.com",
want: []string{"http://localhost:8080", "https://example.com"},
},
{
name: "empty",
input: "",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := NewBuilder(nil)
got := b.parseOriginsList(tt.input)
assert.Equal(t, tt.want, got)
})
}
}

76
pkg/bootstrap/saml.go Normal file
View File

@@ -0,0 +1,76 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package bootstrap
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"time"
)
const (
samlCertValidityYears = 10
samlKeyBits = 2048
)
// GenerateSAMLCertificate generates a self-signed certificate and private key
// for SAML authentication. The certificate is valid for 10 years.
func GenerateSAMLCertificate() (cert string, key string, err error) {
privateKey, err := rsa.GenerateKey(rand.Reader, samlKeyBits)
if err != nil {
return "", "", fmt.Errorf("generate RSA key: %w", err)
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", "", fmt.Errorf("generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "probo-saml",
Organization: []string{"Probo"},
Country: []string{"US"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(samlCertValidityYears, 0, 0),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return "", "", fmt.Errorf("create certificate: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
return string(certPEM), string(keyPEM), nil
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package bootstrap
import (
"crypto/x509"
"encoding/pem"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateSAMLCertificate(t *testing.T) {
cert, key, err := GenerateSAMLCertificate()
require.NoError(t, err)
certBlock, _ := pem.Decode([]byte(cert))
require.NotNil(t, certBlock, "certificate should be valid PEM")
assert.Equal(t, "CERTIFICATE", certBlock.Type)
keyBlock, _ := pem.Decode([]byte(key))
require.NotNil(t, keyBlock, "private key should be valid PEM")
assert.Equal(t, "RSA PRIVATE KEY", keyBlock.Type)
parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
require.NoError(t, err)
assert.Equal(t, "probo-saml", parsedCert.Subject.CommonName)
assert.Equal(t, []string{"Probo"}, parsedCert.Subject.Organization)
assert.Equal(t, []string{"US"}, parsedCert.Subject.Country)
assert.True(t, parsedCert.NotBefore.Before(time.Now().Add(time.Minute)))
assert.True(t, parsedCert.NotAfter.After(time.Now().AddDate(9, 0, 0)))
assert.True(t, parsedCert.NotAfter.Before(time.Now().AddDate(11, 0, 0)))
}
func TestGenerateSAMLCertificate_UniqueSerials(t *testing.T) {
cert1, _, err := GenerateSAMLCertificate()
require.NoError(t, err)
cert2, _, err := GenerateSAMLCertificate()
require.NoError(t, err)
block1, _ := pem.Decode([]byte(cert1))
block2, _ := pem.Decode([]byte(cert2))
parsed1, err := x509.ParseCertificate(block1.Bytes)
require.NoError(t, err)
parsed2, err := x509.ParseCertificate(block2.Bytes)
require.NoError(t, err)
assert.NotEqual(t, parsed1.SerialNumber, parsed2.SerialNumber)
}

44
pkg/bootstrap/write.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"go.probo.inc/probo/pkg/probod"
"gopkg.in/yaml.v3"
)
// WriteConfig writes the configuration to the specified path as YAML.
// It creates the parent directory if it doesn't exist.
func WriteConfig(cfg *probod.FullConfig, path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
return nil
}

153
pkg/bootstrap/write_test.go Normal file
View File

@@ -0,0 +1,153 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package bootstrap
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/probod"
"gopkg.in/yaml.v3"
)
func TestWriteConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "probod.yml")
cfg := &probod.FullConfig{
Unit: probod.UnitConfig{
Metrics: probod.MetricsConfig{Addr: "localhost:9090"},
},
Probod: probod.Config{
BaseURL: "http://localhost:8080",
EncryptionKey: "test-key",
},
}
err := WriteConfig(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
var loaded probod.FullConfig
err = yaml.Unmarshal(data, &loaded)
require.NoError(t, err)
assert.Equal(t, cfg.Unit.Metrics.Addr, loaded.Unit.Metrics.Addr)
assert.Equal(t, cfg.Probod.BaseURL, loaded.Probod.BaseURL)
assert.Equal(t, cfg.Probod.EncryptionKey, loaded.Probod.EncryptionKey)
}
func TestWriteConfig_CreatesDirectory(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "nested", "dir", "probod.yml")
cfg := &probod.FullConfig{
Probod: probod.Config{BaseURL: "http://localhost:8080"},
}
err := WriteConfig(cfg, configPath)
require.NoError(t, err)
_, err = os.Stat(configPath)
require.NoError(t, err)
}
func TestWriteConfig_FilePermissions(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "probod.yml")
cfg := &probod.FullConfig{}
err := WriteConfig(cfg, configPath)
require.NoError(t, err)
info, err := os.Stat(configPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
func TestWriteConfig_CompleteConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "probod.yml")
cfg := &probod.FullConfig{
Unit: probod.UnitConfig{
Metrics: probod.MetricsConfig{Addr: "localhost:8081"},
Tracing: probod.TracingConfig{
Addr: "localhost:4317",
MaxBatchSize: 512,
BatchTimeout: 5,
ExportTimeout: 30,
MaxQueueSize: 2048,
},
},
Probod: probod.Config{
BaseURL: "http://localhost:8080",
EncryptionKey: "test-key",
ChromeDPAddr: "localhost:9222",
Api: probod.APIConfig{
Addr: ":8080",
Cors: probod.CorsConfig{
AllowedOrigins: []string{"http://localhost:8080"},
},
ExtraHeaderFields: map[string]string{},
},
Pg: probod.PgConfig{
Addr: "localhost:5432",
Username: "postgres",
Password: "postgres",
Database: "probod",
PoolSize: 100,
},
Connectors: []probod.ConnectorConfig{
{
Provider: "slack",
Protocol: "oauth2",
RawConfig: probod.ConnectorConfigOAuth2{
ClientID: "client-id",
ClientSecret: "client-secret",
Scopes: []string{"chat:write"},
},
RawSettings: map[string]interface{}{
"signing-secret": "secret",
},
},
},
},
}
err := WriteConfig(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
var loaded probod.FullConfig
err = yaml.Unmarshal(data, &loaded)
require.NoError(t, err)
assert.Equal(t, cfg.Unit.Metrics.Addr, loaded.Unit.Metrics.Addr)
assert.Equal(t, cfg.Unit.Tracing.MaxBatchSize, loaded.Unit.Tracing.MaxBatchSize)
assert.Equal(t, cfg.Probod.Api.Cors.AllowedOrigins, loaded.Probod.Api.Cors.AllowedOrigins)
assert.Equal(t, cfg.Probod.Pg.PoolSize, loaded.Probod.Pg.PoolSize)
require.Len(t, loaded.Probod.Connectors, 1)
assert.Equal(t, "slack", loaded.Probod.Connectors[0].Provider)
}