Skip to content

Commit

Permalink
Enabling Local Development on Macs with Apple Silicon (arm64) 🍏 (DevB…
Browse files Browse the repository at this point in the history
…etterCom#1274)

* Add docker compose with a sql edge container

* Updates razor markup so that the new Local environment includes and excludes client side resources the same as Development

* Remove commented code in the Dockerfile

* Test migrations only run for the 'Local' environment:

- Extract the actual migration apply to a separate service to test the environment logic.
- Add unit test for the MigrationService extension

* Remove docker ignore from csproj configuration section

* Code refactor: Clean empty lines

* Removes Local environment in favor of docker environment variable file

- Now migrations are run for "Development" environment, but only if running from a container
- Removes appsettings.Local.json
- Removes Local environment from razor markdown
- Updates unit tests

* Adds README instructions

Signed-off-by: davidchaparro <[email protected]>

* Put 'Run with docker' section after the 'EF Migrations Commands' section

Signed-off-by: davidchaparro <[email protected]>

* Fixes null reference warning

Signed-off-by: davidchaparro <[email protected]>

---------

Signed-off-by: davidchaparro <[email protected]>
  • Loading branch information
david-acm authored Sep 11, 2024
1 parent 1afca88 commit da4817c
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 3 deletions.
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
LOCAL_SQL_PASSWORD=L0c4l_S3cr3t_P4a5sw0rD
3 changes: 3 additions & 0 deletions DevBetterWeb.sln
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
efscripts.txt = efscripts.txt
global.json = global.json
README.md = README.md
Dockerfile = Dockerfile
docker-compose.yml = docker-compose.yml
.env = .env
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevBetterWeb.UnitTests", "tests\DevBetterWeb.UnitTests\DevBetterWeb.UnitTests.csproj", "{0AB375BC-E7AD-4BD9-8D28-AC07351CE37A}"
Expand Down
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
ARG BUILD_CONFIGURATION=Debug
COPY *.sln .
COPY . .
RUN dotnet restore
WORKDIR "/src/DevBetterWeb.Web"
RUN dotnet build "DevBetterWeb.Web.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Debug
RUN dotnet publish "DevBetterWeb.Web.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
# Optional: Set this here if not setting it from docker-compose.yml
ENV ASPNETCORE_ENVIRONMENT=Local
ENTRYPOINT ["dotnet", "DevBetterWeb.Web.dll"]
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Head over to [devBetter.com](https://devbetter.com) to see the live site. Scroll

### Building and Running the App Locally

You can both run the app manually by running the SQL migrations, or by using `docker-compose` see [this section](#run-with-docker)

- Clone (or Fork and Clone) the repository locally
- Run migrations for both AppDbContext and IdentityDbContext

Expand All @@ -64,7 +66,7 @@ You should create an **appsettings.development.json** file to hold your other co

For the Discord web hook integration, you can use the `dev-test` channel in devBetter's Discord server. The web hook url is in the channel description on Discord. You can use that in you appsettings.development.json. Alternatively, you can set up your own Discord server - see [here](https://ardalis.com/add-discord-notifications-to-asp-net-core-apps/) for a walkthrough - and add the url to appsettings.development.json in the Discord Webhooks section that you can copy from appsettings.json. You could also create a mock server which will provide you with a url to use - an example is mocky.io

## EF Migrations Commands
### EF Migrations Commands

Add a new migration (from the DevBetter.Web folder):

Expand All @@ -90,6 +92,48 @@ Generate Idempotent Update Script (for production)(from the DevBetter.Web folder
dotnet ef migrations script -c AppDbContext -i -o migrate.sql -p ../DevBetterWeb.Infrastructure/DevBetterWeb.Infrastructure.csproj -s DevBetterWeb.Web.csproj
```

### Run with Docker

Alternatively you can use `docker-compose` to run the app locally. This is specially helpful when launching the app in operative systems that don't have support for SQL Express like MacOS.

To run with docker compose, simply run in the root of the repo:

```bash
docker compose up
```

The multi-container app runs two services:
- The web project: at `https://localhost/`
- The SQL Edge container at `localhost:1433`.

By default the application will run in the `Development` environment, and the SQL migrations will be applied programatically by the web project container during startup.

If you want to access the database outside of the app you will need the local password for the database, which you can find in this [.env file](https://github.com/DevBetterCom/DevBetterWeb/blob/main/.env). You can also find the full connection string as an environment variable for the web project with the name `ConnectionStrings:DefaultConnection` running in bash:

```bash
docker inspect \
--format='{{range .Config.Env}}{{println .}}{{end}}' dev-better-web \
| grep "ConnectionStrings:DefaultConnection=" \
| cut -d '=' -f 2- \
| sed 's/database/localhost/g'
```

Or in PowerShell:

```powershell
docker inspect `
--format='{{range .Config.Env}}{{println .}}{{end}}' dev-better-web `
| Select-String "ConnectionStrings:DefaultConnection=" `
| ForEach-Object { $_.Line -replace "database", "localhost" } `
| ForEach-Object { ($_ -split "Connection=")[1] }
```

To stop the services run:

```bash
docker compose down
```

## Video Upload Instructions (admin only)

Put the video files and their associated markdown files in a folder you wish to upload from. Specify the Vimeo token and devBetter API key.
Expand Down
38 changes: 38 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
services:
web:
image: dev-better-web
container_name: dev-better-web
depends_on:
- database
ports:
- 80:80
build:
context: .
dockerfile: Dockerfile
networks:
- my-network
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings:DefaultConnection: Server=database,1433;MultipleActiveResultSets=true;User Id=sa;Password=${LOCAL_SQL_PASSWORD};Encrypt=false

database:
image: mcr.microsoft.com/azure-sql-edge
cap_add:
- SYS_PTRACE
environment:
- ACCEPT_EULA=1
- MSSQL_SA_PASSWORD=${LOCAL_SQL_PASSWORD}
ports:
- 1433:1433
container_name: ms-sql
command:
- "/bin/sh"
- "-c"
- "/opt/mssql/bin/launchpadd -usens=false -enableOutboundAccess=true -usesameuser=true -sqlGroup root -- -reparentOrphanedDescendants=true -useDefaultLaunchers=false & /app/asdepackage/AsdePackage & /opt/mssql/bin/sqlservr"
privileged: true
networks:
- my-network

networks:
my-network:

43 changes: 43 additions & 0 deletions src/DevBetterWeb.Web/MigrationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace DevBetterWeb.Web;

public interface ILocalMigrationService<TContext> where TContext : DbContext
{
Task ApplyDatabaseMigrationsAsync();
}

public class MigrationService<TContext> : ILocalMigrationService<TContext>
where TContext : DbContext
{
private readonly ILogger<MigrationService<TContext>> _logger;

private readonly TContext _dbContext;

public MigrationService(
TContext context,
ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<MigrationService<TContext>>();
_dbContext = context;
}

public async Task ApplyDatabaseMigrationsAsync()
{
if (await NoPendingMigrationsAsync(_dbContext))
{
_logger.LogInformation($"No pending migrations to apply for context: {typeof(TContext).Name}");
return;
}

_logger.LogInformation($"Applying pending migrations for context: {typeof(TContext).Name}...");
await _dbContext.Database.MigrateAsync();
_logger.LogInformation($"Migrations applied successfully for context: {typeof(TContext).Name}");
}

private static async Task<bool> NoPendingMigrationsAsync(TContext dbContext) =>
!(await dbContext.Database.GetPendingMigrationsAsync()).Any();
}
22 changes: 22 additions & 0 deletions src/DevBetterWeb.Web/MigrationServiceExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace DevBetterWeb.Web;

public static class MigrationServiceExtensions {

public static async Task ApplyLocalMigrationAsync<TContext>(
this ILocalMigrationService<TContext> migrationService,
string environmentName,
bool runningIncontainer)
where TContext : DbContext
{
if (environmentName is not "Development"
|| !runningIncontainer)
{
return;
}

await migrationService.ApplyDatabaseMigrationsAsync();
}
}
20 changes: 18 additions & 2 deletions src/DevBetterWeb.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
builder.Services.AddScoped<ILeaderboardService, LeaderboardService>();
builder.Services.AddScoped<IAddCreatedVideoToFolderService, AddCreatedVideoToFolderService>();
builder.Services.AddScoped<ICreateVideoService, CreateVideoService>();
builder.Services.AddScoped(typeof(ILocalMigrationService<>), typeof(MigrationService<>));

VimeoSettings vimeoSettings = builder.Configuration.GetSection(Constants.ConfigKeys.VimeoSettings)!.Get<VimeoSettings>()!;
builder.Services.AddSingleton(vimeoSettings);
Expand Down Expand Up @@ -195,11 +196,11 @@
app.MapDefaultControllerRoute();

// seed database
await ApplyLocalMigrationsAsync(app);
await SeedDatabase(app);

app.Run();


static async Task SeedDatabase(IHost host)
{
using var scope = host.Services.CreateScope();
Expand All @@ -211,7 +212,7 @@ static async Task SeedDatabase(IHost host)
var context = services.GetRequiredService<AppDbContext>();
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
SeedData.PopulateInitData(context, userManager);

if (environment == "Production")
{
return;
Expand Down Expand Up @@ -251,6 +252,21 @@ static async Task SeedDatabase(IHost host)
}
}

async Task ApplyLocalMigrationsAsync(WebApplication webApplication)
{
using var scope = webApplication.Services.CreateScope();

var identity = scope.ServiceProvider.GetRequiredService<ILocalMigrationService<IdentityDbContext>>();

var app = scope.ServiceProvider.GetRequiredService<ILocalMigrationService<AppDbContext>>();

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty;
bool.TryParse(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), out var runningInContainer);

await identity.ApplyLocalMigrationAsync(environment, runningInContainer);
await app.ApplyLocalMigrationAsync(environment, runningInContainer);
}

//static IHostBuilder CreateHostBuilder(string[] args) =>
// Host.CreateDefaultBuilder(args)
// .UseSerilog()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Threading.Tasks;
using DevBetterWeb.Web;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;

namespace DevBetterWeb.UnitTests.Web.Extensions;

public class MigrationServiceExtensionsTests
{
[Theory]
[InlineData("Development", true, 1)]
[InlineData("Development", false, 0)]
[InlineData("Prod", true, 0)]
public async Task OnlyMigratesWhenEnvironmentIsDevelopment(
string environment,
bool runningInContainer,
int expectedRuns)
{
var migrationService = Substitute.For<ILocalMigrationService<DbContext>>();
await migrationService.ApplyLocalMigrationAsync(environment, runningInContainer);

await migrationService.Received(expectedRuns).ApplyDatabaseMigrationsAsync();
}
}

0 comments on commit da4817c

Please sign in to comment.