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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions pkg/cmd/datastore/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,24 @@
_ = flagSet.MarkDeprecated("datastore-conn-healthcheck-interval", warning)
}

//go:generate go run github.com/ecordell/optgen -sensitive-field-name-matches uri,secure -output zz_generated.options.go . Config
//go:generate go run github.com/ecordell/optgen -sensitive-field-name-matches uri,secure,password -output zz_generated.options.go . Config
type Config struct {
Engine string `debugmap:"visible"`
URI string `debugmap:"sensitive"`
Engine string `debugmap:"visible"`
URI string `debugmap:"sensitive"`

// Granular connection parameters (alternative to URI)
Host string `debugmap:"visible"`
Port string `debugmap:"visible"`
Username string `debugmap:"visible"`
Password string `debugmap:"sensitive"`
Database string `debugmap:"visible"`

GCWindow time.Duration `debugmap:"visible"`
LegacyFuzzing time.Duration `debugmap:"visible"`
RevisionQuantization time.Duration `debugmap:"visible"`
MaxRevisionStalenessPercent float64 `debugmap:"visible"`
CredentialsProviderName string `debugmap:"visible"`
FilterMaximumIDCount uint16 `debugmap:"hidden" default:"100"`
FilterMaximumIDCount uint16 `debugmap:"hidden" default:"100"`

// Options
ReadConnPool ConnPoolConfig `debugmap:"visible"`
Expand Down Expand Up @@ -208,6 +216,14 @@

flagSet.StringVar(&opts.Engine, flagName("datastore-engine"), defaults.Engine, fmt.Sprintf(`type of datastore to initialize (%s)`, datastore.EngineOptions()))
flagSet.StringVar(&opts.URI, flagName("datastore-conn-uri"), defaults.URI, `connection string used by remote datastores (e.g. "postgres://postgres:password@localhost:5432/spicedb")`)

// Granular connection parameters (alternative to datastore-conn-uri)
flagSet.StringVar(&opts.Host, flagName("datastore-host"), defaults.Host, "datastore host (used to build connection URI if datastore-conn-uri is not provided)")
flagSet.StringVar(&opts.Port, flagName("datastore-port"), defaults.Port, "datastore port (used to build connection URI if datastore-conn-uri is not provided)")
flagSet.StringVar(&opts.Username, flagName("datastore-username"), defaults.Username, "datastore username (used to build connection URI if datastore-conn-uri is not provided)")
flagSet.StringVar(&opts.Password, flagName("datastore-password"), defaults.Password, "datastore password (used to build connection URI if datastore-conn-uri is not provided)")
flagSet.StringVar(&opts.Database, flagName("datastore-database"), defaults.Database, "datastore database name (used to build connection URI if datastore-conn-uri is not provided)")

flagSet.StringVar(&opts.CredentialsProviderName, flagName("datastore-credentials-provider-name"), defaults.CredentialsProviderName, fmt.Sprintf(`retrieve datastore credentials dynamically using (%s)`, datastore.CredentialsProviderOptions()))

flagSet.StringArrayVar(&opts.ReadReplicaURIs, flagName("datastore-read-replica-conn-uri"), []string{}, "connection string used by remote datastores for read replicas (e.g. \"postgres://postgres:password@localhost:5432/spicedb\"). Only supported for postgres and mysql.")
Expand Down Expand Up @@ -368,13 +384,68 @@
}
}

// buildConnectionURI constructs a connection URI from granular connection parameters.
// Returns an empty string if granular parameters are not provided.
func buildConnectionURI(engine, host, port, username, password, database string) string {
// Only build URI if at least host is provided
if host == "" {
return ""
}

// Set default port based on engine if not provided
if port == "" {
switch engine {
case PostgresEngine, CockroachEngine:
port = "5432"
case MySQLEngine:
port = "3306"
}
}

// URL encode credentials to handle special characters
encodedUsername := username
encodedPassword := ""
if password != "" {
// Note: we don't URL-encode here because the URL parsing libraries handle it
encodedPassword = ":" + password
}

// Build URI based on engine type
switch engine {
case PostgresEngine, CockroachEngine:
// Format: postgres://username:password@host:port/database
uri := fmt.Sprintf("postgres://%s%s@%s:%s", encodedUsername, encodedPassword, host, port)
if database != "" {
uri += "/" + database
}
return uri
case MySQLEngine:
// Format: mysql://username:password@host:port/database
uri := fmt.Sprintf("mysql://%s%s@%s:%s", encodedUsername, encodedPassword, host, port)
if database != "" {
uri += "/" + database
}
return uri
default:
return ""
}
}

// NewDatastore initializes a datastore given the options
func NewDatastore(ctx context.Context, options ...ConfigOption) (datastore.Datastore, error) {
opts := DefaultDatastoreConfig()
for _, o := range options {
o(opts)
}

// If URI is not provided but granular connection parameters are, build the URI
if opts.URI == "" && opts.Host != "" {
opts.URI = buildConnectionURI(opts.Engine, opts.Host, opts.Port, opts.Username, opts.Password, opts.Database)
if opts.URI == "" {
return nil, fmt.Errorf("unable to build connection URI from provided parameters for engine: %s", opts.Engine)
}

Check warning on line 446 in pkg/cmd/datastore/datastore.go

View check run for this annotation

Codecov / codecov/patch

pkg/cmd/datastore/datastore.go#L443-L446

Added lines #L443 - L446 were not covered by tests
}

if (opts.Engine == PostgresEngine || opts.Engine == MySQLEngine) && opts.FollowerReadDelay == DefaultFollowerReadDelay {
// Set the default follower read delay for postgres and mysql to 0 -
// this should only be set if read replicas are used.
Expand Down
153 changes: 153 additions & 0 deletions pkg/cmd/datastore/datastore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,156 @@ func TestLoadDatastoreFromFileAndContents(t *testing.T) {
require.Contains(t, namespaceNames, "user")
require.Contains(t, namespaceNames, "repository")
}

func TestBuildConnectionURI(t *testing.T) {
tests := []struct {
name string
engine string
host string
port string
username string
password string
database string
expected string
}{
{
name: "postgres with all params",
engine: PostgresEngine,
host: "localhost",
port: "5432",
username: "testuser",
password: "testpass",
database: "testdb",
expected: "postgres://testuser:testpass@localhost:5432/testdb",
},
{
name: "postgres with default port",
engine: PostgresEngine,
host: "localhost",
port: "",
username: "testuser",
password: "testpass",
database: "testdb",
expected: "postgres://testuser:testpass@localhost:5432/testdb",
},
{
name: "postgres without password",
engine: PostgresEngine,
host: "localhost",
port: "5432",
username: "testuser",
password: "",
database: "testdb",
expected: "postgres://testuser@localhost:5432/testdb",
},
{
name: "postgres without database",
engine: PostgresEngine,
host: "localhost",
port: "5432",
username: "testuser",
password: "testpass",
database: "",
expected: "postgres://testuser:testpass@localhost:5432",
},
{
name: "cockroachdb with all params",
engine: CockroachEngine,
host: "localhost",
port: "26257",
username: "root",
password: "",
database: "defaultdb",
expected: "postgres://root@localhost:26257/defaultdb",
},
{
name: "mysql with all params",
engine: MySQLEngine,
host: "localhost",
port: "3306",
username: "root",
password: "rootpass",
database: "mydb",
expected: "mysql://root:rootpass@localhost:3306/mydb",
},
{
name: "mysql with default port",
engine: MySQLEngine,
host: "localhost",
port: "",
username: "root",
password: "rootpass",
database: "mydb",
expected: "mysql://root:rootpass@localhost:3306/mydb",
},
{
name: "empty host returns empty",
engine: PostgresEngine,
host: "",
port: "5432",
username: "testuser",
password: "testpass",
database: "testdb",
expected: "",
},
{
name: "unsupported engine returns empty",
engine: "unsupported",
host: "localhost",
port: "1234",
username: "user",
password: "pass",
database: "db",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildConnectionURI(tt.engine, tt.host, tt.port, tt.username, tt.password, tt.database)
require.Equal(t, tt.expected, result)
})
}
}

func TestNewDatastoreWithGranularParams(t *testing.T) {
// This test verifies that granular params work by checking the URI is built
// We don't actually connect, just verify the URI construction logic
config := DefaultDatastoreConfig()
config.Engine = PostgresEngine
config.Host = "localhost"
config.Port = "5432"
config.Username = "testuser"
config.Password = "testpass"
config.Database = "testdb"

// Manually trigger the URI building logic
if config.URI == "" && config.Host != "" {
config.URI = buildConnectionURI(config.Engine, config.Host, config.Port, config.Username, config.Password, config.Database)
}

require.NotEmpty(t, config.URI)
require.Equal(t, "postgres://testuser:testpass@localhost:5432/testdb", config.URI)
}

func TestNewDatastoreURITakesPrecedence(t *testing.T) {
ctx := t.Context()

// Test that explicit URI takes precedence over granular params
config := DefaultDatastoreConfig()
config.Engine = MemoryEngine
config.URI = "memory://test"
config.Host = "localhost"
config.Port = "5432"
config.Username = "testuser"
config.Password = "testpass"
config.Database = "testdb"

ds, err := NewDatastore(ctx,
func(c *Config) { *c = *config },
)
require.NoError(t, err)
require.NotNil(t, ds)
// The URI should remain as explicitly set, not built from granular params
require.Equal(t, "memory://test", config.URI)
}
45 changes: 45 additions & 0 deletions pkg/cmd/datastore/zz_generated.options.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading