diff --git a/internal/controlplane/database_manager.go b/internal/controlplane/database_manager.go index 05d1ab3..6daf415 100644 --- a/internal/controlplane/database_manager.go +++ b/internal/controlplane/database_manager.go @@ -164,12 +164,20 @@ func OpenDatabaseManager(configPathOverride string) (*DatabaseManager, error) { manager.profiles[profile.ID] = profile manager.order = append(manager.order, profile.ID) } + manager.mu.Lock() + if err := manager.ensureStartupBootstrapDatabaseLocked(context.Background()); err != nil { + manager.mu.Unlock() + _ = manager.catalog.Close() + return nil, err + } manager.ensureDefaultDatabaseLocked() if err := manager.saveRegistryLocked(); err != nil { + manager.mu.Unlock() _ = manager.catalog.Close() return nil, err } + manager.mu.Unlock() if err := manager.refreshWorkspaceCatalog(context.Background()); err != nil { manager.Close() return nil, err @@ -398,11 +406,47 @@ func (m *DatabaseManager) ensureBootstrapDatabase(ctx context.Context) error { return m.ensureBootstrapDatabaseLocked(ctx) } +func (m *DatabaseManager) ensureStartupBootstrapDatabaseLocked(ctx context.Context) error { + profile, ok := m.bootstrapDatabaseProfile(ctx) + if !ok { + return nil + } + profile, hasWorkspaces := adoptBootstrapWorkspaceIdentity(ctx, profile) + if !hasWorkspaces { + return nil + } + if err := validateDatabaseProfile(profile); err != nil { + return err + } + + changed := false + if updated := m.normalizeBootstrapProfilesLocked(profile); updated { + changed = true + } + if existing, exists := m.profiles[profile.ID]; exists { + if existing != profile { + m.profiles[profile.ID] = profile + changed = true + } + if changed { + m.ensureDefaultDatabaseLocked() + return m.saveRegistryLocked() + } + return nil + } + + m.profiles[profile.ID] = profile + m.order = append(m.order, profile.ID) + m.ensureDefaultDatabaseLocked() + return m.saveRegistryLocked() +} + func (m *DatabaseManager) ensureBootstrapDatabaseLocked(ctx context.Context) error { profile, ok := m.bootstrapDatabaseProfile(ctx) if !ok { return nil } + profile, _ = adoptBootstrapWorkspaceIdentity(ctx, profile) if err := validateDatabaseProfile(profile); err != nil { return err } @@ -435,6 +479,47 @@ func (m *DatabaseManager) ensureBootstrapDatabaseLocked(ctx context.Context) err return nil } +func adoptBootstrapWorkspaceIdentity(ctx context.Context, profile databaseProfile) (databaseProfile, bool) { + if normalizedDatabaseManagementType(profile.ManagementType) != databaseManagementUserManaged { + return profile, false + } + + runtime, err := openDatabaseRuntime(ctx, profile) + if err != nil { + return profile, false + } + defer runtime.closeFn() + + metas, err := runtime.store.ListWorkspaces(ctx) + if err != nil || len(metas) == 0 { + return profile, false + } + + var databaseID string + var databaseName string + for _, meta := range metas { + meta = applyWorkspaceMetaDefaults(runtime.cfg, meta) + if strings.TrimSpace(meta.DatabaseID) == "" || strings.TrimSpace(meta.DatabaseName) == "" { + return profile, true + } + if databaseID == "" { + databaseID = strings.TrimSpace(meta.DatabaseID) + databaseName = strings.TrimSpace(meta.DatabaseName) + continue + } + if strings.TrimSpace(meta.DatabaseID) != databaseID || strings.TrimSpace(meta.DatabaseName) != databaseName { + return profile, true + } + } + if databaseID == "" || databaseName == "" { + return profile, true + } + + profile.ID = databaseID + profile.Name = databaseName + return profile, true +} + func (m *DatabaseManager) bootstrapDatabaseProfile(ctx context.Context) (databaseProfile, bool) { if profile, ok := bootstrapDatabaseProfileFromContext(ctx); ok { return profile, true diff --git a/internal/controlplane/database_manager_registry_test.go b/internal/controlplane/database_manager_registry_test.go index fd46d91..a1cf008 100644 --- a/internal/controlplane/database_manager_registry_test.go +++ b/internal/controlplane/database_manager_registry_test.go @@ -12,14 +12,15 @@ import ( "github.com/alicebob/miniredis/v2" ) -func TestOpenDatabaseManagerStartsWithEmptyRegistry(t *testing.T) { +func TestOpenDatabaseManagerLeavesEmptyConfigStoreUnregisteredOnStartup(t *testing.T) { t.Helper() dir := t.TempDir() configPath := filepath.Join(dir, "afs.config.json") + mr := miniredis.RunT(t) cfg := Config{ RedisConfig: RedisConfig{ - RedisAddr: "localhost:6380", + RedisAddr: mr.Addr(), RedisDB: 0, }, } @@ -42,11 +43,61 @@ func TestOpenDatabaseManagerStartsWithEmptyRegistry(t *testing.T) { t.Fatalf("ListDatabaseProfiles() returned error: %v", err) } if len(profiles) != 0 { - t.Fatalf("len(ListDatabaseProfiles()) = %d, want 0 (control plane should start empty)", len(profiles)) + t.Fatalf("len(ListDatabaseProfiles()) = %d, want 0", len(profiles)) + } +} + +func TestOpenDatabaseManagerListsExistingWorkspacesWithoutManualDatabaseCreate(t *testing.T) { + mr := miniredis.RunT(t) + dir := t.TempDir() + configPath := filepath.Join(dir, "afs.config.json") + cfg := Config{ + RedisConfig: RedisConfig{ + RedisAddr: mr.Addr(), + RedisDB: 0, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("json.Marshal(cfg) returned error: %v", err) + } + if err := os.WriteFile(configPath, append(data, '\n'), 0o600); err != nil { + t.Fatalf("WriteFile(config) returned error: %v", err) } - if _, err := os.Stat(filepath.Join(dir, "afs.databases.json")); !os.IsNotExist(err) { - t.Fatalf("afs.databases.json should not be written, stat err = %v", err) + store, closeFn, err := OpenStore(context.Background(), cfg) + if err != nil { + t.Fatalf("OpenStore() returned error: %v", err) + } + t.Cleanup(closeFn) + + if err := createWorkspaceWithMetadata(context.Background(), cfg, store, "repo", workspaceCreateSpec{ + Description: "Preexisting local workspace.", + Source: sourceBlank, + }); err != nil { + t.Fatalf("createWorkspaceWithMetadata() returned error: %v", err) + } + + manager, err := OpenDatabaseManager(configPath) + if err != nil { + t.Fatalf("OpenDatabaseManager() returned error: %v", err) + } + defer manager.Close() + + wantDatabaseID, _ := activeDatabaseIdentity(cfg) + workspaces, err := manager.ListAllWorkspaceSummaries(context.Background()) + if err != nil { + t.Fatalf("ListAllWorkspaceSummaries() returned error: %v", err) + } + if len(workspaces.Items) != 1 { + t.Fatalf("len(ListAllWorkspaceSummaries().Items) = %d, want 1", len(workspaces.Items)) + } + if workspaces.Items[0].Name != "repo" { + t.Fatalf("workspaces.Items[0].Name = %q, want %q", workspaces.Items[0].Name, "repo") + } + if workspaces.Items[0].DatabaseID != wantDatabaseID { + t.Fatalf("workspaces.Items[0].DatabaseID = %q, want %q", workspaces.Items[0].DatabaseID, wantDatabaseID) } } diff --git a/internal/controlplane/http_test.go b/internal/controlplane/http_test.go index 021d8ff..dac79d7 100644 --- a/internal/controlplane/http_test.go +++ b/internal/controlplane/http_test.go @@ -2455,8 +2455,6 @@ func newTestManager(t *testing.T) (*DatabaseManager, string) { if err := createWorkspaceWithMetadata(ctx, cfg, store, "repo", workspaceCreateSpec{ Description: "Control plane demo workspace.", - DatabaseID: "db-demo", - DatabaseName: "demo-db-us-test-1", CloudAccount: "Redis Cloud / Test", Region: "us-test-1", Source: sourceGitImport, diff --git a/internal/controlplane/quickstart.go b/internal/controlplane/quickstart.go index 67008a8..39e035a 100644 --- a/internal/controlplane/quickstart.go +++ b/internal/controlplane/quickstart.go @@ -162,18 +162,7 @@ func bootstrapDatabaseProfileFromEnv() (databaseProfile, bool) { func bootstrapDatabaseProfileFromContext(_ context.Context) (databaseProfile, bool) { if cfg, ok := redisConfigFromAFSEnv(); ok { - return databaseProfile{ - ID: "local-development", - Name: quickstartLocalDBName, - Description: "Configured from AFS_REDIS_* environment variables.", - ManagementType: databaseManagementUserManaged, - RedisAddr: cfg.RedisAddr, - RedisUsername: cfg.RedisUsername, - RedisPassword: cfg.RedisPassword, - RedisDB: cfg.RedisDB, - RedisTLS: cfg.RedisTLS, - IsDefault: true, - }, true + return bootstrapUserManagedDatabaseProfile(Config{RedisConfig: cfg}, "Configured from AFS_REDIS_* environment variables."), true } if cfg, ok := redisConfigFromURL(strings.TrimSpace(os.Getenv("REDIS_URL"))); ok { @@ -201,10 +190,15 @@ func bootstrapDatabaseProfileFromConfigPath(configPathOverride string) (database if err != nil || !present || strings.TrimSpace(cfg.RedisAddr) == "" { return databaseProfile{}, false } + return bootstrapUserManagedDatabaseProfile(cfg, "Configured from afs.config.json."), true +} + +func bootstrapUserManagedDatabaseProfile(cfg Config, description string) databaseProfile { + databaseID, _ := activeDatabaseIdentity(cfg) return databaseProfile{ - ID: "local-development", + ID: databaseID, Name: quickstartLocalDBName, - Description: "Configured from afs.config.json.", + Description: description, ManagementType: databaseManagementUserManaged, RedisAddr: cfg.RedisAddr, RedisUsername: cfg.RedisUsername, @@ -212,7 +206,7 @@ func bootstrapDatabaseProfileFromConfigPath(configPathOverride string) (database RedisDB: cfg.RedisDB, RedisTLS: cfg.RedisTLS, IsDefault: true, - }, true + } } // quickstartWithDatabase creates the getting-started workspace on an existing database.