diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b3c2624 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.github +.agents +.bifrost +.voidbot +bin +obj +**/bin +**/obj +**/.vs +**/.vscode +**/.idea +**/TestResults +**/*.user +**/*.suo +.env +.env.* +!.env.local.example +README.md +docs +plugins +tools diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..ab751e7 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,4 @@ +# Optional local overrides for Docker Compose. +# The default compose.local.yaml is self-contained for local smoke testing. + +COMPOSE_PROJECT_NAME=bifrost-local diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml new file mode 100644 index 0000000..d9d1fba --- /dev/null +++ b/.github/workflows/publish-container.yml @@ -0,0 +1,54 @@ +name: Publish Container + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: gamecult/bifrost + +jobs: + publish: + name: Build and publish Bifrost image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=sha- + type=ref,event=branch + type=ref,event=tag + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b591b8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj + +COPY . . +RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ + --configuration Release \ + --output /app/publish \ + /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \ + ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \ + DOTNET_RUNNING_IN_CONTAINER=true + +EXPOSE 5080 +COPY --from=build /app/publish . + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:5080/healthz || exit 1 + +ENTRYPOINT ["dotnet", "Bifrost.Web.dll"] diff --git a/README.md b/README.md index f2745a4..8f1efbb 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - health and readiness endpoints plus startup config validation - GitHub App webhook ingestion for issue, pull request, and review sync - local bridge tooling for agent-owned GitHub draft PRs and Discord posts +- Docker image and local Compose stack for containerized smoke testing - xUnit integration tests under `tests/Bifrost.Web.Tests` - matching deploy artifacts and runbooks in `E:\Projects\gamecult-ops` @@ -41,7 +42,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m - public app hostname: `bifrost.gamecult.org` - infrastructure context: `Yggdrasil` -- deployment model: nginx + systemd + localhost Postgres, following the existing GameCult ops pattern +- deployment model: GHCR image + Docker Compose behind nginx, with the older systemd path kept as rollback until the container path is boring ## Next Build Target @@ -62,6 +63,8 @@ For the full staged roadmap, including contribution points, revenue share, patro - default connection string points at local PostgreSQL - build with `dotnet build Bifrost.slnx` - test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed +- container smoke test with `docker compose -f compose.local.yaml up --build` +- container health checks live at `http://127.0.0.1:5080/healthz` and `http://127.0.0.1:5080/readyz` ## Read First diff --git a/compose.local.yaml b/compose.local.yaml new file mode 100644 index 0000000..dbd3d85 --- /dev/null +++ b/compose.local.yaml @@ -0,0 +1,50 @@ +name: bifrost-local + +services: + postgres: + image: postgres:16 + container_name: bifrost-postgres + environment: + POSTGRES_DB: bifrost_dev + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "127.0.0.1:55432:5432" + volumes: + - bifrost-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d bifrost_dev"] + interval: 10s + timeout: 5s + retries: 5 + + web: + build: + context: . + dockerfile: Dockerfile + image: ghcr.io/gamecult/bifrost:local + container_name: bifrost-web + depends_on: + postgres: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: http://0.0.0.0:5080 + ConnectionStrings__Bifrost: Host=postgres;Database=bifrost_dev;Username=postgres;Password=postgres + Bootstrap__ApplyMigrationsOnStartup: "true" + GitHubOAuth__ClientId: local-dev-client + GitHubOAuth__ClientSecret: local-dev-secret + GitHubApp__EnableWebhookSync: "false" + Host__PublicBaseUrl: http://localhost:5080 + Host__ExpectedHost: localhost + Host__RequireStrictHostValidation: "false" + DataProtection__KeysPath: /var/lib/bifrost/data-protection-keys + AllowedHosts: localhost;127.0.0.1 + ports: + - "127.0.0.1:5080:5080" + volumes: + - bifrost-data-protection:/var/lib/bifrost/data-protection-keys + +volumes: + bifrost-postgres: + bifrost-data-protection: diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 691269a..b3021c2 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpLogging; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -67,23 +68,34 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services +var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"]; +if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath)) +{ + builder.Services + .AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)); +} + +var configuredGitHubOAuthOptions = builder.Configuration + .GetSection(GitHubOAuthOptions.SectionName) + .Get() ?? new GitHubOAuthOptions(); + +var authenticationBuilder = builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/auth/sign-in"; options.AccessDeniedPath = "/Membership/Status"; options.SlidingExpiration = true; - }) - .AddOAuth(GitHubAuthenticationDefaults.AuthenticationScheme, options => - { - var gitHubOptions = builder.Configuration - .GetSection(GitHubOAuthOptions.SectionName) - .Get() ?? new GitHubOAuthOptions(); + }); - options.ClientId = gitHubOptions.ClientId; - options.ClientSecret = gitHubOptions.ClientSecret; - options.CallbackPath = gitHubOptions.CallbackPath; +if (configuredGitHubOAuthOptions.IsConfigured) +{ + authenticationBuilder.AddOAuth(GitHubAuthenticationDefaults.AuthenticationScheme, options => + { + options.ClientId = configuredGitHubOAuthOptions.ClientId; + options.ClientSecret = configuredGitHubOAuthOptions.ClientSecret; + options.CallbackPath = configuredGitHubOAuthOptions.CallbackPath; options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize"; options.TokenEndpoint = "https://github.com/login/oauth/access_token"; options.UserInformationEndpoint = "https://api.github.com/user"; @@ -118,6 +130,7 @@ builder.Services } }; }); +} builder.Services.AddAuthorization(options => {