From af64a2e4fcb7cab4cb6455f3a675d585bcd8ec63 Mon Sep 17 00:00:00 2001 From: Dmitry Makarenko Date: Tue, 27 Oct 2026 19:27:44 +0000 Subject: [PATCH 1/3] feat: take the network ID from config and refuse to start on any doubt Product Kratos releases must use the network that config names, never the oldest row in networks. Init now reads network.id (required except for migrate) and network.forbidden_id (optional), and fails fast on a missing, invalid, nil, forbidden or unknown network. No row is created. Claude-Session: https://claude.ai/code/session_01LdtTNtaWbgWyKokPFfvUL3 --- driver/config/config.go | 10 ++ driver/factory_test.go | 18 ++- driver/registry_default.go | 53 +++++++- driver/registry_default_network_test.go | 142 ++++++++++++++++++++ embedx/config.schema.json | 20 +++ internal/testhelpers/e2e_server.go | 12 ++ persistence/sql/migratest/migration_test.go | 1 + persistence/sql/persister.go | 9 ++ 8 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 driver/registry_default_network_test.go diff --git a/driver/config/config.go b/driver/config/config.go index d1e28d166b98..d0c2e1ce9200 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -55,6 +55,8 @@ const ( DefaultCipherAlgorithm = "noop" UnknownVersion = "unknown version" ViperKeyDSN = "dsn" + ViperKeyNetworkID = "network.id" + ViperKeyForbiddenNetworkID = "network.forbidden_id" ViperKeyCourierSMTPURL = "courier.smtp.connection_uri" ViperKeyCourierSMTPClientCertPath = "courier.smtp.client_cert_path" ViperKeyCourierSMTPClientKeyPath = "courier.smtp.client_key_path" @@ -658,6 +660,14 @@ func (p *Config) DSN(ctx context.Context) string { return "" } +func (p *Config) NetworkID(ctx context.Context) string { + return p.GetProvider(ctx).String(ViperKeyNetworkID) +} + +func (p *Config) ForbiddenNetworkID(ctx context.Context) string { + return p.GetProvider(ctx).String(ViperKeyForbiddenNetworkID) +} + func (p *Config) DisableAPIFlowEnforcement(ctx context.Context) bool { if p.IsInsecureDevMode(ctx) && os.Getenv("DEV_DISABLE_API_FLOW_ENFORCEMENT") == "true" { p.l.Warn("Because \"DEV_DISABLE_API_FLOW_ENFORCEMENT=true\" and the \"--dev\" flag are set, self-service API flows will no longer check if the interaction is actually a browser flow. This is very dangerous as it allows bypassing of anti-CSRF measures, leaving the deployment highly vulnerable. This option should only be used for automated testing and never come close to real user data anywhere.") diff --git a/driver/factory_test.go b/driver/factory_test.go index d5c646550a87..19f0731fc834 100644 --- a/driver/factory_test.go +++ b/driver/factory_test.go @@ -13,6 +13,8 @@ import ( "github.com/gofrs/uuid" "github.com/ory/x/configx" + "github.com/ory/x/dbal" + "github.com/ory/x/networkx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,21 +25,31 @@ import ( func TestDriverNew(t *testing.T) { ctx := context.Background() + dsn := dbal.NewSQLiteTestDatabase(t) + nid := uuid.Must(uuid.NewV4()) + + migrator, err := driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), []driver.RegistryOption{driver.SkipNetworkInit}, + []configx.OptionModifier{configx.WithValue(config.ViperKeyDSN, dsn), configx.SkipValidation()}) + require.NoError(t, err) + require.NoError(t, migrator.Persister().MigrateUp(ctx)) + require.NoError(t, migrator.Persister().GetConnection(ctx).Create(&networkx.Network{ID: nid})) + r, err := driver.New( context.Background(), os.Stderr, servicelocatorx.NewOptions(), nil, []configx.OptionModifier{ - configx.WithValue(config.ViperKeyDSN, config.DefaultSQLiteMemoryDSN), + configx.WithValue(config.ViperKeyDSN, dsn), + configx.WithValue(config.ViperKeyNetworkID, nid.String()), configx.SkipValidation(), }) require.NoError(t, err) - assert.EqualValues(t, config.DefaultSQLiteMemoryDSN, r.Config().DSN(ctx)) + assert.EqualValues(t, dsn, r.Config().DSN(ctx)) require.NoError(t, r.Persister().Ping()) - assert.NotEqual(t, uuid.Nil.String(), r.Persister().NetworkID(context.Background()).String()) + assert.Equal(t, nid, r.Persister().NetworkID(context.Background())) n, err := r.Persister().DetermineNetwork(context.Background()) require.NoError(t, err) diff --git a/driver/registry_default.go b/driver/registry_default.go index 9f88c7375dd0..4e03cbfe3b01 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -17,6 +17,7 @@ import ( "github.com/cenkalti/backoff" "github.com/dgraph-io/ristretto" "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" "github.com/gorilla/sessions" "github.com/hashicorp/go-retryablehttp" "github.com/luna-duclos/instrumentedsql" @@ -643,6 +644,14 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize m.identitySchemaProvider = o.replaceIdentitySchemaProvider(m) } + var nid uuid.UUID + if !o.skipNetworkInit { + var err error + if nid, err = m.configuredNetworkID(ctx); err != nil { + return err + } + } + bc := backoff.NewExponentialBackOff() bc.MaxElapsedTime = time.Minute * 5 bc.Reset() @@ -697,12 +706,16 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize return nil } - net, err := p.DetermineNetwork(ctx) - if err != nil { - m.Logger().WithError(err).Warnf("Unable to determine network, retrying.") + net, err := p.NetworkByID(ctx, nid) + if errors.Is(err, sqlcon.ErrNoRows) { + // backoff v2 detects a permanent error only by type assertion, so it must not be wrapped. + return backoff.Permanent(errors.Errorf("config value %q names network %s, which is not in table networks", config.ViperKeyNetworkID, nid)) + } else if err != nil { + m.Logger().WithError(err).Warnf("Unable to load network, retrying.") return err } + m.Logger().WithField("network_id", net.ID.String()).Info("Using the network from config.") m.persister = p.WithNetworkID(net.ID) return nil }, bc) @@ -718,6 +731,40 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize return nil } +func (m *RegistryDefault) configuredNetworkID(ctx context.Context) (uuid.UUID, error) { + nid, err := parseNetworkID(config.ViperKeyNetworkID, m.Config().NetworkID(ctx)) + if err != nil { + return uuid.Nil, err + } + + raw := m.Config().ForbiddenNetworkID(ctx) + if raw == "" { + return nid, nil + } + forbidden, err := parseNetworkID(config.ViperKeyForbiddenNetworkID, raw) + if err != nil { + return uuid.Nil, err + } + if nid == forbidden { + return uuid.Nil, errors.Errorf("config value %q equals config value %q: this release must not use network %s", config.ViperKeyNetworkID, config.ViperKeyForbiddenNetworkID, nid) + } + return nid, nil +} + +func parseNetworkID(key, raw string) (uuid.UUID, error) { + if raw == "" { + return uuid.Nil, errors.Errorf("required config value %q was not set", key) + } + id, err := uuid.FromString(raw) + if err != nil { + return uuid.Nil, errors.Errorf("config value %q is not a UUID: %q", key, raw) + } + if id == uuid.Nil { + return uuid.Nil, errors.Errorf("config value %q must not be the nil UUID", key) + } + return id, nil +} + func (m *RegistryDefault) SetPersister(p persistence.Persister) { m.persister = p } diff --git a/driver/registry_default_network_test.go b/driver/registry_default_network_test.go new file mode 100644 index 000000000000..5f1e74b4c885 --- /dev/null +++ b/driver/registry_default_network_test.go @@ -0,0 +1,142 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +// ABOUTME: Tests that registry Init takes the network ID from config and refuses to start on any doubt. + +package driver_test + +import ( + "context" + "encoding/base64" + "os" + "testing" + "time" + + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/x/configx" + "github.com/ory/x/dbal" + "github.com/ory/x/networkx" + "github.com/ory/x/servicelocatorx" + "github.com/ory/x/sqlcon/dockertest" +) + +func TestInitNetworkFromConfig(t *testing.T) { + t.Run("db=sqlite", func(t *testing.T) { + testInitNetworkFromConfig(t, dbal.NewSQLiteTestDatabase(t)) + }) + + t.Run("db=cockroach", func(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + testInitNetworkFromConfig(t, dockertest.RunTestCockroachDBWithVersion(t, "latest-v23.1")) + }) +} + +func newRegistry(ctx context.Context, values map[string]interface{}, dOpts ...driver.RegistryOption) (driver.Registry, error) { + return driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), dOpts, + []configx.OptionModifier{configx.WithValues(values), configx.SkipValidation()}) +} + +func countNetworks(t *testing.T, c *pop.Connection) int { + n, err := c.Count(&networkx.Network{}) + require.NoError(t, err) + return n +} + +func testInitNetworkFromConfig(t *testing.T, dsn string) { + ctx := context.Background() + + migrator, err := newRegistry(ctx, map[string]interface{}{config.ViperKeyDSN: dsn}, driver.SkipNetworkInit) + require.NoError(t, err, "network init that is skipped, as migrate does, needs no network key") + require.NoError(t, migrator.Persister().MigrateUp(ctx)) + conn := migrator.Persister().GetConnection(ctx) + + older := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now().Add(-time.Hour)} + newer := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now()} + require.NoError(t, conn.Create(&older)) + require.NoError(t, conn.Create(&newer)) + + for _, tc := range []struct { + name string + values map[string]interface{} + errorHas string + }{ + {name: "key missing", values: map[string]interface{}{}, errorHas: config.ViperKeyNetworkID}, + {name: "key not a UUID", values: map[string]interface{}{config.ViperKeyNetworkID: "not-a-uuid"}, errorHas: "not-a-uuid"}, + {name: "key is the nil UUID", values: map[string]interface{}{config.ViperKeyNetworkID: uuid.Nil.String()}, errorHas: config.ViperKeyNetworkID}, + {name: "key names no row", values: map[string]interface{}{config.ViperKeyNetworkID: uuid.Must(uuid.NewV4()).String()}, errorHas: "networks"}, + { + name: "key equals the forbidden key", + values: map[string]interface{}{ + config.ViperKeyNetworkID: newer.ID.String(), + config.ViperKeyForbiddenNetworkID: newer.ID.String(), + }, + errorHas: config.ViperKeyForbiddenNetworkID, + }, + { + name: "forbidden key not a UUID", + values: map[string]interface{}{ + config.ViperKeyNetworkID: newer.ID.String(), + config.ViperKeyForbiddenNetworkID: "classic", + }, + errorHas: "classic", + }, + { + name: "forbidden key is the nil UUID", + values: map[string]interface{}{ + config.ViperKeyNetworkID: newer.ID.String(), + config.ViperKeyForbiddenNetworkID: uuid.Nil.String(), + }, + errorHas: config.ViperKeyForbiddenNetworkID, + }, + } { + t.Run("case="+tc.name, func(t *testing.T) { + before := countNetworks(t, conn) + tc.values[config.ViperKeyDSN] = dsn + + start := time.Now() + _, err := newRegistry(ctx, tc.values) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errorHas) + assert.Less(t, time.Since(start), 30*time.Second, "a config error must not wait for the database retry") + assert.Equal(t, before, countNetworks(t, conn), "a refused start must not create a network") + }) + } + + for _, tc := range []struct { + name string + values map[string]interface{} + }{ + {name: "valid key", values: map[string]interface{}{config.ViperKeyNetworkID: newer.ID.String()}}, + {name: "valid key and other forbidden key", values: map[string]interface{}{ + config.ViperKeyNetworkID: newer.ID.String(), + config.ViperKeyForbiddenNetworkID: older.ID.String(), + }}, + } { + t.Run("case="+tc.name, func(t *testing.T) { + tc.values[config.ViperKeyDSN] = dsn + tc.values[config.ViperKeyIdentitySchemas] = config.Schemas{{ + ID: config.DefaultIdentityTraitsSchemaID, + URL: "base64://" + base64.StdEncoding.EncodeToString([]byte(`{"type":"object","properties":{"traits":{"type":"object"}}}`)), + }} + + reg, err := newRegistry(ctx, tc.values) + require.NoError(t, err) + assert.Equal(t, newer.ID, reg.Persister().NetworkID(ctx), "config must win over the oldest network row") + + i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) + var stored identity.Identity + require.NoError(t, conn.Where("id = ?", i.ID).First(&stored)) + assert.Equal(t, newer.ID, stored.NID) + }) + } +} diff --git a/embedx/config.schema.json b/embedx/config.schema.json index a45c423076dc..d46a77d75bf9 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2023,6 +2023,26 @@ "sqlite:///var/lib/sqlite/db.sqlite?_fk=true&mode=rwc" ] }, + "network": { + "type": "object", + "title": "Network", + "description": "Selects the row in table `networks` that scopes all data of this process. Every command except `migrate` refuses to start when `id` is not set or names no row.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Network ID", + "description": "The ID of an existing row in table `networks`." + }, + "forbidden_id": { + "type": "string", + "format": "uuid", + "title": "Forbidden Network ID", + "description": "The process refuses to start when `id` equals this value." + } + }, + "additionalProperties": false + }, "courier": { "type": "object", "title": "Courier configuration", diff --git a/internal/testhelpers/e2e_server.go b/internal/testhelpers/e2e_server.go index b4841d4d080c..05b5664806b5 100644 --- a/internal/testhelpers/e2e_server.go +++ b/internal/testhelpers/e2e_server.go @@ -23,9 +23,14 @@ import ( "testing" "time" + "github.com/gofrs/uuid" + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/config" "github.com/ory/x/dbal" "github.com/ory/x/jsonnetsecure" + "github.com/ory/x/networkx" + "github.com/ory/x/servicelocatorx" "golang.org/x/sync/errgroup" @@ -72,10 +77,12 @@ func startE2EServerOnly(t *testing.T, configFile string, isTLS bool, configOptio dbt, err := os.MkdirTemp(os.TempDir(), "ory-kratos-e2e-examples-*") require.NoError(t, err) dsn := "sqlite://" + filepath.Join(dbt, "db.sqlite") + "?_fk=true&mode=rwc" + nid := uuid.Must(uuid.NewV4()) ctx := configx.ContextWithConfigOptions( context.Background(), configx.WithValue("dsn", dsn), + configx.WithValue(config.ViperKeyNetworkID, nid.String()), configx.WithValue("dev", true), configx.WithValue("log.level", "error"), configx.WithValue("log.leak_sensitive_values", true), @@ -104,6 +111,11 @@ func startE2EServerOnly(t *testing.T, configFile string, isTLS bool, configOptio _ = executor.ExecNoErr(t, "migrate", "sql", dsn, "--yes") t.Logf("Migration done") + migrated, err := driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), []driver.RegistryOption{driver.SkipNetworkInit}, + []configx.OptionModifier{configx.WithValue("dsn", dsn), configx.SkipValidation()}) + require.NoError(t, err) + require.NoError(t, migrated.Persister().GetConnection(ctx).Create(&networkx.Network{ID: nid})) + t.Log("Starting server...") stdOut, stdErr := &bytes.Buffer{}, &bytes.Buffer{} eg := executor.ExecBackground(nil, stdErr, stdOut, "serve", "--config", configFile, "--watch-courier") diff --git a/persistence/sql/migratest/migration_test.go b/persistence/sql/migratest/migration_test.go index bf727683248b..8f3bd1fee4f1 100644 --- a/persistence/sql/migratest/migration_test.go +++ b/persistence/sql/migratest/migration_test.go @@ -150,6 +150,7 @@ func testDatabase(t *testing.T, db string, c *pop.Connection) { []configx.OptionModifier{ configx.WithValues(map[string]interface{}{ config.ViperKeyDSN: url, + config.ViperKeyNetworkID: "884f556e-eb3a-4b9f-bee3-11345642c6c0", config.ViperKeyPublicBaseURL: "https://www.ory.sh/", config.ViperKeyIdentitySchemas: config.Schemas{{ID: "default", URL: "file://stub/default.schema.json"}}, config.ViperKeySecretsDefault: []string{"secret"}, diff --git a/persistence/sql/persister.go b/persistence/sql/persister.go index 85bcdf7466c8..46591be9a52f 100644 --- a/persistence/sql/persister.go +++ b/persistence/sql/persister.go @@ -27,6 +27,7 @@ import ( "github.com/ory/x/networkx" "github.com/ory/x/otelx" "github.com/ory/x/popx" + "github.com/ory/x/sqlcon" ) var _ persistence.Persister = new(Persister) @@ -133,6 +134,14 @@ func (p *Persister) DetermineNetwork(ctx context.Context) (*networkx.Network, er return p.p.Determine(ctx) } +func (p *Persister) NetworkByID(ctx context.Context, id uuid.UUID) (*networkx.Network, error) { + var n networkx.Network + if err := sqlcon.HandleError(p.Connection(ctx).Find(&n, id)); err != nil { + return nil, err + } + return &n, nil +} + func (p *Persister) Connection(ctx context.Context) *pop.Connection { return p.c.WithContext(ctx) } From b0a18a99e3d42341f080532bdf10c17f395815b4 Mon Sep 17 00:00:00 2001 From: Dmitry Makarenko Date: Tue, 27 Oct 2026 20:23:59 +0000 Subject: [PATCH 2/3] fix: seed the configured network for a memory DSN and prove the pre-retry checks A memory SQLite DSN lives only in this process, so nothing else can seed its networks row and every non-migrate command refused to start. Init now inserts the configured row for a memory DSN only, after all key checks, and logs a warning. Config-error tests run against an unreachable DSN and assert exact messages, so they fail if a check is removed or moved into the retry. The cleanup sql help example sets NETWORK_ID. --- cmd/cleanup/sql.go | 2 + driver/registry_default.go | 10 +- driver/registry_default_network_test.go | 124 ++++++++++++++++-------- embedx/config.schema.json | 2 +- 4 files changed, 94 insertions(+), 44 deletions(-) diff --git a/cmd/cleanup/sql.go b/cmd/cleanup/sql.go index 8a7680d3bcb4..1c6f7bbdfd7b 100644 --- a/cmd/cleanup/sql.go +++ b/cmd/cleanup/sql.go @@ -27,7 +27,9 @@ It is recommended to run this command close to the SQL instance (e.g. same subne This decreases risk of failure and decreases time required. You can read in the database URL using the -e flag, for example: export DSN=... + export NETWORK_ID=... kratos cleanup sql -e +NETWORK_ID (config key network.id) must name an existing row in table networks. ### WARNING ### Before running this command on an existing database, create a back up! `, diff --git a/driver/registry_default.go b/driver/registry_default.go index 4e03cbfe3b01..2fe3912dddcf 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -62,6 +62,7 @@ import ( "github.com/ory/x/jsonnetsecure" "github.com/ory/x/jwksx" "github.com/ory/x/logrusx" + "github.com/ory/x/networkx" "github.com/ory/x/otelx" otelsql "github.com/ory/x/otelx/sql" "github.com/ory/x/popx" @@ -693,7 +694,8 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize } // if dsn is memory we have to run the migrations on every start - if dbal.IsMemorySQLite(m.Config().DSN(ctx)) || m.Config().DSN(ctx) == "memory" { + isMemory := dbal.IsMemorySQLite(m.Config().DSN(ctx)) || m.Config().DSN(ctx) == "memory" + if isMemory { m.Logger().Infoln("Ory Kratos is running migrations on every startup as DSN is memory. This means your data is lost when Kratos terminates.") if err := p.MigrateUp(ctx); err != nil { m.Logger().WithError(err).Warnf("Unable to run migrations, retrying.") @@ -707,6 +709,12 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize } net, err := p.NetworkByID(ctx, nid) + if errors.Is(err, sqlcon.ErrNoRows) && isMemory { + // Only this process can reach a memory database, so nothing else can seed its network. + m.Logger().WithField("network_id", nid.String()).Warn("Inserting the network from config, because the DSN is a memory database. This happens only for a memory DSN.") + net = &networkx.Network{ID: nid} + err = p.GetConnection(ctx).Create(net) + } if errors.Is(err, sqlcon.ErrNoRows) { // backoff v2 detects a permanent error only by type assertion, so it must not be wrapped. return backoff.Permanent(errors.Errorf("config value %q names network %s, which is not in table networks", config.ViperKeyNetworkID, nid)) diff --git a/driver/registry_default_network_test.go b/driver/registry_default_network_test.go index 5f1e74b4c885..cbab49031c56 100644 --- a/driver/registry_default_network_test.go +++ b/driver/registry_default_network_test.go @@ -9,6 +9,7 @@ import ( "context" "encoding/base64" "os" + "path/filepath" "testing" "time" @@ -28,8 +29,10 @@ import ( ) func TestInitNetworkFromConfig(t *testing.T) { + t.Run("config errors refuse before the database retry", testConfigErrorsRefuseBeforeRetry) + t.Run("db=sqlite", func(t *testing.T) { - testInitNetworkFromConfig(t, dbal.NewSQLiteTestDatabase(t)) + testInitNetworkFromConfig(t, "sqlite://"+filepath.Join(t.TempDir(), "db.sqlite")+"?_fk=true&mode=rwc") }) t.Run("db=cockroach", func(t *testing.T) { @@ -38,6 +41,24 @@ func TestInitNetworkFromConfig(t *testing.T) { } testInitNetworkFromConfig(t, dockertest.RunTestCockroachDBWithVersion(t, "latest-v23.1")) }) + + t.Run("db=memory", func(t *testing.T) { + ctx := context.Background() + nid := uuid.Must(uuid.NewV4()) + + reg, err := newRegistry(ctx, map[string]interface{}{ + config.ViperKeyDSN: dbal.NewSQLiteTestDatabase(t), + config.ViperKeyNetworkID: nid.String(), + }) + require.NoError(t, err, "a memory database lives only in this process, so nothing else can seed its network") + assert.Equal(t, nid, reg.Persister().NetworkID(ctx)) + + n, err := reg.Persister().(interface { + NetworkByID(context.Context, uuid.UUID) (*networkx.Network, error) + }).NetworkByID(ctx, nid) + require.NoError(t, err) + assert.Equal(t, nid, n.ID) + }) } func newRegistry(ctx context.Context, values map[string]interface{}, dOpts ...driver.RegistryOption) (driver.Registry, error) { @@ -51,65 +72,84 @@ func countNetworks(t *testing.T, c *pop.Connection) int { return n } -func testInitNetworkFromConfig(t *testing.T, dsn string) { +func testConfigErrorsRefuseBeforeRetry(t *testing.T) { ctx := context.Background() - - migrator, err := newRegistry(ctx, map[string]interface{}{config.ViperKeyDSN: dsn}, driver.SkipNetworkInit) - require.NoError(t, err, "network init that is skipped, as migrate does, needs no network key") - require.NoError(t, migrator.Persister().MigrateUp(ctx)) - conn := migrator.Persister().GetConnection(ctx) - - older := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now().Add(-time.Hour)} - newer := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now()} - require.NoError(t, conn.Create(&older)) - require.NoError(t, conn.Create(&newer)) + // Nothing listens on port 1, so any database call would retry for minutes. + unreachable := "postgres://kratos:secret@127.0.0.1:1/kratos?sslmode=disable" + valid := uuid.Must(uuid.NewV4()).String() for _, tc := range []struct { - name string - values map[string]interface{} - errorHas string + name string + values map[string]interface{} + error string }{ - {name: "key missing", values: map[string]interface{}{}, errorHas: config.ViperKeyNetworkID}, - {name: "key not a UUID", values: map[string]interface{}{config.ViperKeyNetworkID: "not-a-uuid"}, errorHas: "not-a-uuid"}, - {name: "key is the nil UUID", values: map[string]interface{}{config.ViperKeyNetworkID: uuid.Nil.String()}, errorHas: config.ViperKeyNetworkID}, - {name: "key names no row", values: map[string]interface{}{config.ViperKeyNetworkID: uuid.Must(uuid.NewV4()).String()}, errorHas: "networks"}, { - name: "key equals the forbidden key", - values: map[string]interface{}{ - config.ViperKeyNetworkID: newer.ID.String(), - config.ViperKeyForbiddenNetworkID: newer.ID.String(), - }, - errorHas: config.ViperKeyForbiddenNetworkID, + name: "key missing", + values: map[string]interface{}{}, + error: `required config value "network.id" was not set`, + }, + { + name: "key not a UUID", + values: map[string]interface{}{config.ViperKeyNetworkID: "not-a-uuid"}, + error: `config value "network.id" is not a UUID: "not-a-uuid"`, + }, + { + name: "key is the nil UUID", + values: map[string]interface{}{config.ViperKeyNetworkID: uuid.Nil.String()}, + error: `config value "network.id" must not be the nil UUID`, + }, + { + name: "key equals the forbidden key", + values: map[string]interface{}{config.ViperKeyNetworkID: valid, config.ViperKeyForbiddenNetworkID: valid}, + error: `config value "network.id" equals config value "network.forbidden_id": this release must not use network ` + valid, }, { - name: "forbidden key not a UUID", - values: map[string]interface{}{ - config.ViperKeyNetworkID: newer.ID.String(), - config.ViperKeyForbiddenNetworkID: "classic", - }, - errorHas: "classic", + name: "forbidden key not a UUID", + values: map[string]interface{}{config.ViperKeyNetworkID: valid, config.ViperKeyForbiddenNetworkID: "classic"}, + error: `config value "network.forbidden_id" is not a UUID: "classic"`, }, { - name: "forbidden key is the nil UUID", - values: map[string]interface{}{ - config.ViperKeyNetworkID: newer.ID.String(), - config.ViperKeyForbiddenNetworkID: uuid.Nil.String(), - }, - errorHas: config.ViperKeyForbiddenNetworkID, + name: "forbidden key is the nil UUID", + values: map[string]interface{}{config.ViperKeyNetworkID: valid, config.ViperKeyForbiddenNetworkID: uuid.Nil.String()}, + error: `config value "network.forbidden_id" must not be the nil UUID`, }, } { t.Run("case="+tc.name, func(t *testing.T) { - before := countNetworks(t, conn) - tc.values[config.ViperKeyDSN] = dsn + tc.values[config.ViperKeyDSN] = unreachable start := time.Now() _, err := newRegistry(ctx, tc.values) require.Error(t, err) - assert.Contains(t, err.Error(), tc.errorHas) - assert.Less(t, time.Since(start), 30*time.Second, "a config error must not wait for the database retry") - assert.Equal(t, before, countNetworks(t, conn), "a refused start must not create a network") + assert.Equal(t, tc.error, err.Error()) + assert.Less(t, time.Since(start), 10*time.Second, "a config error must not wait for the database retry") }) } +} + +func testInitNetworkFromConfig(t *testing.T, dsn string) { + ctx := context.Background() + + migrator, err := newRegistry(ctx, map[string]interface{}{config.ViperKeyDSN: dsn}, driver.SkipNetworkInit) + require.NoError(t, err, "network init that is skipped, as migrate does, needs no network key") + require.NoError(t, migrator.Persister().MigrateUp(ctx)) + conn := migrator.Persister().GetConnection(ctx) + + older := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now().Add(-time.Hour)} + newer := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now()} + require.NoError(t, conn.Create(&older)) + require.NoError(t, conn.Create(&newer)) + + t.Run("case=key names no row", func(t *testing.T) { + before := countNetworks(t, conn) + unknown := uuid.Must(uuid.NewV4()) + + start := time.Now() + _, err := newRegistry(ctx, map[string]interface{}{config.ViperKeyDSN: dsn, config.ViperKeyNetworkID: unknown.String()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `config value "network.id" names network `+unknown.String()+", which is not in table networks") + assert.Less(t, time.Since(start), 30*time.Second, "a missing row must not wait for the database retry") + assert.Equal(t, before, countNetworks(t, conn), "a refused start must not create a network") + }) for _, tc := range []struct { name string diff --git a/embedx/config.schema.json b/embedx/config.schema.json index d46a77d75bf9..cff95817190d 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2026,7 +2026,7 @@ "network": { "type": "object", "title": "Network", - "description": "Selects the row in table `networks` that scopes all data of this process. Every command except `migrate` refuses to start when `id` is not set or names no row.", + "description": "Selects the row in table `networks` that scopes all data of this process. Every command except `migrate` refuses to start when `id` is not set or names no row. For a memory DSN only, the process inserts the row itself, because no other process can reach that database.", "properties": { "id": { "type": "string", From 0202275e9dfc9d63a00054529565413a839ed133 Mon Sep 17 00:00:00 2001 From: Dmitry Makarenko Date: Tue, 27 Oct 2026 22:30:38 +0000 Subject: [PATCH 3/3] test(e2e): seed the configured network before Kratos starts The e2e runner and Playwright start Kratos with no network.id, so serve refused to start. They now export a fixed NETWORK_ID and run the new test/e2e/seed-network helper between migrate and serve. The helper inserts the row once and skips it when the row exists. --- test/e2e/modd.conf | 1 + test/e2e/playwright.config.ts | 2 ++ test/e2e/run.sh | 1 + test/e2e/seed-network/main.go | 55 ++++++++++++++++++++++++++++++ test/e2e/seed-network/main_test.go | 42 +++++++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 test/e2e/seed-network/main.go create mode 100644 test/e2e/seed-network/main_test.go diff --git a/test/e2e/modd.conf b/test/e2e/modd.conf index c0241db6a80f..63f64ec6a998 100644 --- a/test/e2e/modd.conf +++ b/test/e2e/modd.conf @@ -7,5 +7,6 @@ test/e2e/mock/webhook/*.go { **/*.go !**/*_test.go { prep: go build -tags sqlite,json1 -o test/e2e/.bin/kratos . prep: test/e2e/.bin/kratos migrate sql -e --yes + prep: go run -tags sqlite,json1 ./test/e2e/seed-network daemon +sigterm: test/e2e/.bin/kratos serve --watch-courier --dev -c test/e2e/kratos.generated.yml } diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index a3f81c73060d..e818ec613336 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -47,12 +47,14 @@ export default defineConfig({ command: [ "cp test/e2e/playwright/kratos.base-config.json test/e2e/playwright/kratos.config.json", "go run -tags sqlite,json1 . migrate sql -e --yes", + "go run -tags sqlite,json1 ./test/e2e/seed-network", "go run -tags sqlite,json1 . serve --watch-courier --dev -c test/e2e/playwright/kratos.config.json", ].join(" && "), cwd: "../..", url: "http://localhost:4433/health/ready", env: { DSN: dbToDsn(), + NETWORK_ID: "3df92ae5-e1ac-4087-b8c2-3652ecda4c4f", COURIER_SMTP_CONNECTION_URI: "smtp://localhost:8026/?disable_starttls=true", }, diff --git a/test/e2e/run.sh b/test/e2e/run.sh index a0a6d449fc6c..2662dc83a4ef 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -22,6 +22,7 @@ export DEV_DISABLE_API_FLOW_ENFORCEMENT=true export COOKIE_SECRET=kweifawskf23weas export CSRF_COOKIE_NAME=node_csrf_token export CSRF_COOKIE_SECRET=lkaw9oe8isedrhq2 +export NETWORK_ID=3df92ae5-e1ac-4087-b8c2-3652ecda4c4f base=$(pwd) diff --git a/test/e2e/seed-network/main.go b/test/e2e/seed-network/main.go new file mode 100644 index 000000000000..644205742808 --- /dev/null +++ b/test/e2e/seed-network/main.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +// ABOUTME: Inserts the networks row that NETWORK_ID names into the database that DSN names, so e2e Kratos can start. + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + + "github.com/ory/kratos/driver" + "github.com/ory/x/configx" + "github.com/ory/x/dbal" + "github.com/ory/x/networkx" + "github.com/ory/x/servicelocatorx" +) + +func init() { + dbal.RegisterDriver(func() dbal.Driver { + return driver.NewRegistryDefault() + }) +} + +func seedNetwork(c *pop.Connection, nid uuid.UUID) error { + exists, err := c.Where("id = ?", nid).Exists(&networkx.Network{}) + if err != nil || exists { + return err + } + return c.Create(&networkx.Network{ID: nid}) +} + +func main() { + ctx := context.Background() + nid, err := uuid.FromString(os.Getenv("NETWORK_ID")) + if err != nil || nid == uuid.Nil { + fmt.Fprintf(os.Stderr, "NETWORK_ID must be a UUID that is not nil, got %q\n", os.Getenv("NETWORK_ID")) + os.Exit(1) + } + + reg, err := driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), []driver.RegistryOption{driver.SkipNetworkInit}, + []configx.OptionModifier{configx.SkipValidation()}) + if err != nil { + os.Exit(1) + } + if err := seedNetwork(reg.Persister().GetConnection(ctx), nid); err != nil { + fmt.Fprintf(os.Stderr, "Unable to seed network %s: %v\n", nid, err) + os.Exit(1) + } + fmt.Printf("Network %s is in table networks.\n", nid) +} diff --git a/test/e2e/seed-network/main_test.go b/test/e2e/seed-network/main_test.go new file mode 100644 index 000000000000..d6000f2dadc5 --- /dev/null +++ b/test/e2e/seed-network/main_test.go @@ -0,0 +1,42 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +// ABOUTME: Tests that the e2e seed inserts the configured network once and keeps the row on a rerun. + +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/config" + "github.com/ory/x/configx" + "github.com/ory/x/networkx" + "github.com/ory/x/servicelocatorx" +) + +func TestSeedNetwork(t *testing.T) { + ctx := context.Background() + dsn := "sqlite://" + filepath.Join(t.TempDir(), "db.sqlite") + "?_fk=true&mode=rwc" + reg, err := driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), []driver.RegistryOption{driver.SkipNetworkInit}, + []configx.OptionModifier{configx.WithValue(config.ViperKeyDSN, dsn), configx.SkipValidation()}) + require.NoError(t, err) + require.NoError(t, reg.Persister().MigrateUp(ctx)) + conn := reg.Persister().GetConnection(ctx) + nid := uuid.Must(uuid.NewV4()) + + require.NoError(t, seedNetwork(conn, nid)) + require.NoError(t, seedNetwork(conn, nid), "a rerun of the e2e setup must not fail on the existing row") + + var rows []networkx.Network + require.NoError(t, conn.All(&rows)) + require.Len(t, rows, 1) + assert.Equal(t, nid, rows[0].ID) +}