Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/ReadyStackGo.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ public static async Task Main(string[] args)

var app = builder.Build();

// Initialize SQLite database
app.Services.EnsureDatabaseCreated();
// Apply EF Core migrations (handles fresh DBs, legacy DBs from EnsureCreated era, and upgrades)
app.Services.MigrateDatabase();

// Run distribution-specific bootstrap (idempotent, safe on every startup)
await RunBootstrapperAsync(app);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ public void Configure(EntityTypeBuilder<StackSource> builder)
builder.Property(s => s.GitSslVerify)
.HasDefaultValue(true);

// OCI Registry fields (v0.61)
builder.Property(s => s.RegistryUrl)
.HasMaxLength(255);

builder.Property(s => s.Repository)
.HasMaxLength(500);

builder.Property(s => s.RegistryUsername)
.HasMaxLength(255);

builder.Property(s => s.RegistryPassword)
.HasMaxLength(1000); // Encrypted value may be longer

builder.Property(s => s.TagPattern)
.HasMaxLength(200);

// Indexes
builder.HasIndex(s => s.Name).IsUnique();
builder.HasIndex(s => s.Type);
Expand Down
102 changes: 99 additions & 3 deletions src/ReadyStackGo.Infrastructure.DataAccess/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,108 @@ public static IServiceCollection AddDataAccess(this IServiceCollection services,
}

/// <summary>
/// Ensures the database is created.
/// Applies pending EF Core migrations to the database.
/// Handles three cases:
/// 1. Fresh database → all migrations run, schema created from scratch.
/// 2. Legacy database from before migrations were introduced → InitialCreate
/// is retroactively marked as applied (baseline), subsequent migrations run.
/// 3. Existing migrated database → only new pending migrations run.
/// </summary>
public static void EnsureDatabaseCreated(this IServiceProvider services)
public static void MigrateDatabase(this IServiceProvider services)
{
using var scope = services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ReadyStackGoDbContext>();
context.Database.EnsureCreated();

// Case 2: Detect legacy database (tables exist from EnsureCreated but no
// __EFMigrationsHistory). Insert the InitialCreate migration entry so that
// EF Core treats the existing schema as the baseline.
if (IsLegacyDatabase(context))
{
BaselineLegacyDatabase(context);
}

// Case 1 + 3: Apply all pending migrations (or create schema if fresh).
context.Database.Migrate();
}

/// <summary>
/// Checks if this is a legacy database: tables exist (e.g., StackSources) but
/// EF Core's __EFMigrationsHistory table does not.
/// </summary>
private static bool IsLegacyDatabase(ReadyStackGoDbContext context)
{
// Cannot connect at all → fresh database, not legacy
if (!context.Database.CanConnect())
return false;

var connection = context.Database.GetDbConnection();
var wasClosed = connection.State != System.Data.ConnectionState.Open;
if (wasClosed) connection.Open();

try
{
using var cmd = connection.CreateCommand();
// SQLite: check sqlite_master for table presence
cmd.CommandText =
"SELECT " +
" (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory') AS HasHistory, " +
" (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='StackSources') AS HasStackSources";

using var reader = cmd.ExecuteReader();
if (!reader.Read()) return false;

var hasHistory = reader.GetInt32(0) > 0;
var hasStackSources = reader.GetInt32(1) > 0;

// Legacy: tables exist but no migration history
return !hasHistory && hasStackSources;
}
finally
{
if (wasClosed) connection.Close();
}
}

/// <summary>
/// Creates __EFMigrationsHistory and inserts InitialCreate as already applied,
/// so that Migrate() only runs subsequent migrations (AddOciRegistrySource, etc.)
/// against the existing legacy schema.
/// </summary>
private static void BaselineLegacyDatabase(ReadyStackGoDbContext context)
{
var connection = context.Database.GetDbConnection();
var wasClosed = connection.State != System.Data.ConnectionState.Open;
if (wasClosed) connection.Open();

try
{
using var tx = connection.BeginTransaction();

using (var create = connection.CreateCommand())
{
create.Transaction = tx;
create.CommandText =
"CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" (" +
" \"MigrationId\" TEXT NOT NULL CONSTRAINT \"PK___EFMigrationsHistory\" PRIMARY KEY, " +
" \"ProductVersion\" TEXT NOT NULL" +
");";
create.ExecuteNonQuery();
}

using (var insert = connection.CreateCommand())
{
insert.Transaction = tx;
insert.CommandText =
"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " +
"VALUES ('20260413183518_InitialCreate', '9.0.0');";
insert.ExecuteNonQuery();
}

tx.Commit();
}
finally
{
if (wasClosed) connection.Close();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace ReadyStackGo.Infrastructure.DataAccess;

/// <summary>
/// Design-time factory for EF Core tooling (dotnet-ef migrations).
/// Uses an in-memory SQLite connection so no real database file is required
/// when generating migrations.
/// </summary>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ReadyStackGoDbContext>
{
public ReadyStackGoDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ReadyStackGoDbContext>()
.UseSqlite("Data Source=design-time.db")
.Options;

return new ReadyStackGoDbContext(options);
}
}
Loading
Loading