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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**
!artifacts/
!artifacts/portable/
!artifacts/portable/**
!docker/
!docker/appsettings.json
**/*.db
**/*.db-*
**/*.env
**/appsettings.*.json
53 changes: 49 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,63 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
env:
RELEASE_TAG: ${{ github.ref_name }}
run: tar -czf "artifacts/indirection-${RELEASE_TAG}.tar.gz" -C artifacts/portable .
run: |
cd artifacts/portable
zip -r "../indirection-${RELEASE_TAG}.zip" .
- uses: actions/upload-artifact@v4
if: startsWith(github.ref, 'refs/tags/v')
with:
name: release-portable
path: artifacts/indirection-*.tar.gz
path: artifacts/indirection-*.zip
if-no-files-found: error

release:
docker:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: release-portable
path: artifacts
- name: Extract published application
env:
RELEASE_TAG: ${{ github.ref_name }}
run: unzip "artifacts/indirection-${RELEASE_TAG}.zip" -d artifacts/portable
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v6
id: meta
with:
images: ghcr.io/bpoe/indirection
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

release:
needs: [test, docker]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
Expand All @@ -75,5 +120,5 @@ jobs:
RELEASE_TAG: ${{ github.ref_name }}
run: >-
gh release create "$RELEASE_TAG"
"artifacts/indirection-${RELEASE_TAG}.tar.gz"
"artifacts/indirection-${RELEASE_TAG}.zip"
--verify-tag --title "$RELEASE_TAG" --generate-notes
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0-distroless
USER $APP_UID
WORKDIR /data
WORKDIR /app
COPY artifacts/portable/ ./
COPY docker/appsettings.json ./appsettings.json
EXPOSE 8080
ENTRYPOINT ["dotnet", "Indirection.dll"]
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The suite checks health and the complete create/read/redirect/update/redirect/de

GitHub Actions runs on pull requests targeting `main` and pushes to `main`. It installs .NET 10, caches NuGet packages using the project/configuration files as the cache key, restores, builds the complete solution in Release, and runs only `Indirection.Tests` with the existing coverage configuration. Both line and branch coverage must reach 96%, exceeding the design's >95% requirement; coverage reports are retained as workflow artifacts.

Pushing a version tag matching `v*` runs the same validation before publishing a GitHub Release. After these workflow changes are committed and pushed, create the first release from the intended commit:
Pushing a version tag matching `v*` runs the same validation before publishing a multi-platform image to GHCR and then a GitHub Release. Release creation requires successful tests and Docker publishing. After these workflow changes are committed and pushed, create the first release from the intended commit:

```bash
git switch main
Expand All @@ -80,13 +80,13 @@ git tag v0.1.0
git push origin v0.1.0
```

The release attaches `indirection-v0.1.0.tar.gz` (named for the tag). It contains the published application, default configuration, and dependencies, including native SQLite assets for multiple platforms; it contains no database or credentials. Publishing uses GitHub's built-in token: validation has `contents: read`, and only the release-creation job has `contents: write`. No personal access token is needed.
The release attaches `indirection-v0.1.0.zip` (named for the tag). It contains the published application, default configuration, and dependencies, including native SQLite assets for multiple platforms; it contains no database or credentials. Publishing uses GitHub's built-in token: validation has `contents: read`, the Docker job alone has `packages: write`, and only the release-creation job has `contents: write`. No personal access token is needed.

The archive is runtime-neutral and framework-dependent. It requires the **ASP.NET Core Runtime 10.0** (including the .NET runtime), not the SDK. One archive serves platforms supported by both .NET 10 and the packaged SQLite libraries, including Windows, Linux, and macOS on x64/Arm64. Keep the `runtimes` directory intact. Extract into an application directory and run from there; the database directory must already exist on persistent storage and be writable by the application account. This example uses Bash; on Windows, set the same environment variables with PowerShell and use an absolute Windows database path:

```bash
mkdir -p indirection
tar -xzf indirection-v0.1.0.tar.gz -C indirection
unzip indirection-v0.1.0.zip -d indirection
cd indirection
export DOTNET_ENVIRONMENT=Production
export Authentication__ApiKey="$(openssl rand -base64 32)" # Or supply your existing deployment secret.
Expand All @@ -97,3 +97,53 @@ dotnet Indirection.dll --urls http://127.0.0.1:5080
```

The `--migrate` command initializes or upgrades the database without a source checkout, SDK, or platform-specific migration executable. It uses the same configuration as the server, applies only pending migrations, and exits without starting HTTP; failures return a nonzero exit code. Migration mode does not require an API key. Back up the database and stop the application before upgrading, run the new release's migration command, then restart. Keep state outside the extracted release directory and expose deployed management requests through HTTPS as described above.

Docker images support Linux amd64 and arm64 and use the .NET 10 Azure Linux distroless ASP.NET Core runtime. CI packages the same published binaries from the release ZIP into both images without restoring, compiling, or publishing inside Docker. Pull the published image or publish once locally and package it from the repository root:

```bash
docker pull ghcr.io/bpoe/indirection:latest
dotnet publish src/prod/Indirection -c Release --self-contained false \
-p:UseAppHost=false -p:DebugType=None -p:DebugSymbols=false -o artifacts/portable
docker build -t indirection:local .
# Optional multi-platform build (requires a Buildx builder):
docker buildx build --platform linux/amd64,linux/arm64 -t indirection:local --output type=oci,dest=indirection.oci.tar .
```

A stable `v0.1.0` tag publishes `ghcr.io/bpoe/indirection` tags `0.1.0`, `0.1`, `0`, and `latest`. Prereleases publish their full version (for example `0.2.0-rc.1`) without updating stable aliases or `latest`. Pin a full version for controlled upgrades. Publishing uses Docker's [metadata-action](https://github.com/docker/metadata-action) for version tags and [Buildx](https://github.com/docker/build-push-action) for multi-platform builds and caching.

The included `compose.yaml` exposes HTTP on port 8080 and keeps SQLite in a named volume mounted at `/data`. Supply an API key at runtime, apply migrations explicitly, then start:

```bash
export Authentication__ApiKey="$(openssl rand -base64 32)" # Or your existing deployment secret.
docker compose pull
docker compose run --rm --no-deps indirection --migrate
docker compose up -d
curl --fail http://localhost:8080/health
```

Keep the key in your deployment's secret store and supply it again when recreating the container. Migrations do not require a key. Containers run as the runtime image's built-in non-root user, with `/app` as the working directory and a writable `/data`. The runtime contains no shell or package manager. Startup never applies migrations automatically.

The container includes `docker/appsettings.json` as `/app/appsettings.json`, defaulting to SQLite with `Data Source=/data/urls.db`. The portable ZIP retains its original `Data Source=urls.db` default. To override the complete container configuration, copy `docker/appsettings.json` to a local `appsettings.json`, edit it, and uncomment the read-only file mount in `compose.yaml`. For a direct Docker run, use `--mount type=bind,src="$PWD/appsettings.json",dst=/app/appsettings.json,readonly` on both migration and server containers. Standard ASP.NET Core configuration precedence applies, including environment variables and command-line arguments; neither the image nor Compose sets database-provider or connection-string environment variables. A configured alternative database path is used as supplied; mount writable persistent storage at that location, accessible to UID 1654.

Without Compose, use the same named volume for migrations and serving:

```bash
docker volume create indirection-data
docker run --rm -v indirection-data:/data ghcr.io/bpoe/indirection:latest --migrate
docker run -d --name indirection --restart unless-stopped -p 8080:8080 \
-e Authentication__ApiKey -v indirection-data:/data ghcr.io/bpoe/indirection:latest
```

Mount the entire data directory so SQLite journal, WAL, and shared-memory files persist with the database. A fresh named volume inherits the image directory's non-root ownership; provision bind mounts or existing volumes with equivalent write permissions. Back up SQLite consistently while the service is stopped, including its sidecar files. `docker compose down` retains the data volume; `docker compose down -v` deletes it.

To upgrade, stop the service, back up the database, pull the new image, explicitly migrate using that image, and recreate the container while retaining the volume and API key:

```bash
docker compose stop
# Back up the data volume before continuing.
docker compose pull
docker compose run --rm --no-deps indirection --migrate
docker compose up -d
```

If using a pinned image tag, update it in `compose.yaml` before pulling. Check `/health` and the deployment verification suite after upgrading. Route deployed HTTP through HTTPS as described above.
15 changes: 15 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
indirection:
image: ghcr.io/bpoe/indirection:latest
ports:
- "8080:8080"
environment:
- Authentication__ApiKey
volumes:
- data:/data
# Optional complete configuration override:
# - ./appsettings.json:/app/appsettings.json:ro
restart: unless-stopped

volumes:
data:
15 changes: 15 additions & 0 deletions docker/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"Database": {
"Provider": "Sqlite"
},
"ConnectionStrings": {
"Urls": "Data Source=/data/urls.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Loading