From e09f0c619adb0ef68877b48b8028d5e9dfb383d3 Mon Sep 17 00:00:00 2001 From: Dave Thomas <7sharp9@mail.com> Date: Mon, 3 Aug 2026 17:49:29 +0100 Subject: [PATCH 1/4] build(docker): add DockerBuild target Add tracked FAKE targets for building and running the Docker image, including support for DOCKER_IMAGE/DOCKER_PLATFORM overrides and fail-fast checks for GENPRES_URL_ID and GENPRES_PASSWORD. Update the Dockerfile to copy Directory.Build.props during build and apply the shared app version, and refresh developer/agent docs to reflect the new Docker workflow. --- .github/copilot-instructions.md | 8 ++-- AGENTS.md | 8 ++-- Build.fs | 71 ++++++++++++++++++++++++++++++++- DEVELOPMENT.md | 57 ++++++++++++++++---------- Dockerfile | 13 ++++++ 5 files changed, 126 insertions(+), 31 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2e6aaeb4..35bfc87d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,11 +78,11 @@ dotnet test tests/Informedica.GenUNITS.Tests/ ### Docker -The proprietary `GENPRES_URL_ID` is **not** baked into the image any more. Inject it (and `GENPRES_PASSWORD` for admin operations) at container runtime, ideally via a Docker / Kubernetes secret. +`GENPRES_URL_ID` is required at server startup, in both demo and production mode — not just for admin operations. The proprietary production value is **not** baked into the image; inject it (and `GENPRES_PASSWORD` for admin operations) at container runtime, ideally via a Docker / Kubernetes secret. For local testing without production credentials, use the public demo sheet ID documented in `.env.example`. -- `docker build -t halcwb/genpres .` -- `docker run -it -p 8080:8085 -e GENPRES_URL_ID="your_url_id" -e GENPRES_PASSWORD="your_admin_password" halcwb/genpres` -- `dotnet run DockerRun` - Run pre-built Docker image +- `dotnet run DockerBuild` - Build the image, labelled with the version from the root `Directory.Build.props`. Override the image name with `DOCKER_IMAGE` (default `halcwb/genpres`), cross-build a different platform with `DOCKER_PLATFORM`. +- `dotnet run DockerRun` - Run the built image, reading `GENPRES_URL_ID`/`GENPRES_PASSWORD` from the current environment (source `.env` first) and failing fast if either is unset. +- Equivalent manual commands: `docker build -t halcwb/genpres .` / `docker run -it -p 8080:8085 -e GENPRES_URL_ID="your_url_id" -e GENPRES_PASSWORD="your_admin_password" halcwb/genpres` ## Key Code Locations diff --git a/AGENTS.md b/AGENTS.md index b89eb1fc..1550b993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,11 +78,11 @@ dotnet test tests/Informedica.GenUNITS.Tests/ ### Docker -The proprietary `GENPRES_URL_ID` is **not** baked into the image any more. Inject it (and `GENPRES_PASSWORD` for admin operations) at container runtime, ideally via a Docker / Kubernetes secret. +`GENPRES_URL_ID` is required at server startup, in both demo and production mode — not just for admin operations. The proprietary production value is **not** baked into the image; inject it (and `GENPRES_PASSWORD` for admin operations) at container runtime, ideally via a Docker / Kubernetes secret. For local testing without production credentials, use the public demo sheet ID documented in `.env.example`. -- `docker build -t halcwb/genpres .` -- `docker run -it -p 8080:8085 -e GENPRES_URL_ID="your_url_id" -e GENPRES_PASSWORD="your_admin_password" halcwb/genpres` -- `dotnet run DockerRun` - Run pre-built Docker image +- `dotnet run DockerBuild` - Build the image, labelled with the version from the root `Directory.Build.props`. Override the image name with `DOCKER_IMAGE` (default `halcwb/genpres`), cross-build a different platform with `DOCKER_PLATFORM`. +- `dotnet run DockerRun` - Run the built image, reading `GENPRES_URL_ID`/`GENPRES_PASSWORD` from the current environment (source `.env` first) and failing fast if either is unset. +- Equivalent manual commands: `docker build -t halcwb/genpres .` / `docker run -it -p 8080:8085 -e GENPRES_URL_ID="your_url_id" -e GENPRES_PASSWORD="your_admin_password" halcwb/genpres` ## Key Code Locations diff --git a/Build.fs b/Build.fs index 65e96834..b1e24648 100644 --- a/Build.fs +++ b/Build.fs @@ -277,7 +277,76 @@ Target.create ) -Target.create "DockerRun" (fun _ -> run docker [ "run"; "-it"; "p"; "8080:8085"; "halcwb/genpres" ] ".") +let requireEnvVar name = + match System.Environment.GetEnvironmentVariable name with + | null + | "" -> failwithf "%s is not set. Load it from .env first (see DEVELOPMENT.md)." name + | v -> v + + +// Override via DOCKER_IMAGE if you're pushing to your own registry/namespace +// rather than the project's `halcwb/genpres`. +let dockerImage = + match System.Environment.GetEnvironmentVariable "DOCKER_IMAGE" with + | null + | "" -> "halcwb/genpres" + | image -> image + + +Target.create + "DockerBuild" + (fun _ -> + let version = + System.Xml.Linq.XDocument.Load("Directory.Build.props").Descendants(System.Xml.Linq.XName.Get "Version") + |> Seq.map (fun e -> e.Value) + |> Seq.tryHead + |> Option.defaultWith (fun () -> failwith "Directory.Build.props: element not found") + + // Cross-build for a different target platform, e.g. amd64 from Apple + // Silicon, via: DOCKER_PLATFORM=linux/amd64 dotnet run DockerBuild + let platformArgs = + match System.Environment.GetEnvironmentVariable "DOCKER_PLATFORM" with + | null + | "" -> [] + | platform -> [ "--platform"; platform ] + + run + docker + ([ "build" ] + @ platformArgs + @ [ + "--build-arg" + $"APP_VERSION={version}" + "-t" + dockerImage + "." + ]) + "." + ) + + +Target.create + "DockerRun" + (fun _ -> + let urlId = requireEnvVar "GENPRES_URL_ID" + let password = requireEnvVar "GENPRES_PASSWORD" + + run + docker + [ + "run" + "-it" + "--rm" + "-p" + "8080:8085" + "-e" + $"GENPRES_URL_ID={urlId}" + "-e" + $"GENPRES_PASSWORD={password}" + dockerImage + ] + "." + ) open Fake.Core.TargetOperators diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a87b137e..e678eef3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -68,7 +68,8 @@ packages for the Fable/Vite dev server). | `dotnet run TestHeadless` | `TestHeadless` | Build and run tests without launching a browser | | `dotnet run WatchTests` | `WatchTests` | Run tests in watch mode (re-runs on file changes) | | `dotnet run Format` | `Format` | Format all F# source files using Fantomas | -| `dotnet run DockerRun` | `DockerRun` | Run the pre-built Docker image locally | +| `dotnet run DockerBuild` | `DockerBuild` | Build the production image (`halcwb/genpres` by default, override with `DOCKER_IMAGE`), labelling it with the version from the root `Directory.Build.props` | +| `dotnet run DockerRun` | `DockerRun` | Run the built image locally, using `GENPRES_URL_ID`/`GENPRES_PASSWORD` from the current environment (source `.env` first) | #### Target Dependency Chains @@ -237,38 +238,50 @@ dotnet run ##### Docker wrappers -None of these wrappers bake `GENPRES_URL_ID` into the image — that constraint is enforced by the `Dockerfile` itself and described in [Environment Configuration](#environment-configuration). +Building and running the image no longer needs a hand-copied shell script: the `DockerBuild` +and `DockerRun` FAKE targets (see [FAKE Build Targets Reference](#fake-build-targets-reference)) +cover both, work identically from PowerShell, Git Bash, or any POSIX shell, and are tracked in +`Build.fs` rather than living only as documentation. Neither target bakes `GENPRES_URL_ID` into +the image — that constraint is enforced by the `Dockerfile` itself and described in + [Environment Configuration](#environment-configuration). -**`docker-local.sh`** — build for the local processor architecture (Apple Silicon → arm64; Intel/Linux → amd64). Save at the repo root: +**Build** — `dotnet run DockerBuild` reads the app's single curated version number from the root +`Directory.Build.props` and passes it to `docker build --build-arg APP_VERSION=...`, so the image's +`org.opencontainers.image.version` label always matches what was built. To cross-build for +a different platform set `DOCKER_PLATFORM`; to tag/push under your own name instead of the +project's `halcwb/genpres` default, set `DOCKER_IMAGE` (both `DockerBuild` and `DockerRun` read it). ```bash -#!/usr/bin/env bash -docker build -t halcwb/genpres . -``` +# local architecture +dotnet run DockerBuild -**`docker-amd64.sh`** — cross-build an amd64 image on Apple Silicon for deployment to a typical Linux host. Save at the repo root: +# cross-build amd64 +DOCKER_PLATFORM=linux/amd64 dotnet run DockerBuild +``` -```bash -#!/usr/bin/env bash -docker build --platform linux/amd64 -t halcwb/genpres . +```powershell +# cross-build amd64 (PowerShell) +$env:DOCKER_PLATFORM = "linux/amd64" +dotnet run DockerBuild ``` -**`docker-run.sh`** — source `.env`, validate that both `GENPRES_URL_ID` and `GENPRES_PASSWORD` are set (the `:` parameter expansion fails fast if either is missing), then run the container with the right `-e` flags. Save at the repo root: +**Run** — `dotnet run DockerRun` reads `GENPRES_URL_ID` and `GENPRES_PASSWORD` from the current +environment and fails fast with an error if either is missing, rather than starting an +unauthenticated container that the in-server `validateProductionPassword` would refuse later. +Source `.env` first (single source of truth — same as `prod.sh` / `debug.sh`): ```bash -#!/usr/bin/env bash -# Load env vars from .env (single source of truth — same as prod.sh / debug.sh). set -a; source .env; set +a +dotnet run DockerRun +``` -# Fail fast if .env did not provide the secrets, so we don't start an -# unauthenticated container that the in-server validateProductionPassword -# would refuse later anyway. -: "${GENPRES_URL_ID:?GENPRES_URL_ID is not set in .env}" -: "${GENPRES_PASSWORD:?GENPRES_PASSWORD is not set in .env}" - -docker run -e GENPRES_URL_ID="${GENPRES_URL_ID}" \ - -e GENPRES_PASSWORD="${GENPRES_PASSWORD}" \ - -p 8080:8085 halcwb/genpres +```powershell +Get-Content .env | ForEach-Object { + if ($_ -match '^\s*([^#=]+)=(.*)$') { + [Environment]::SetEnvironmentVariable($Matches[1].Trim(), $Matches[2].Trim()) + } +} +dotnet run DockerRun ``` If you find yourself wanting to commit one of these local scripts (e.g. because the team agrees it should be standardized), add a `!`-prefixed allow-line for the file to `.gitignore` in the same PR — otherwise the opt-in strategy will silently keep it untracked. diff --git a/Dockerfile b/Dockerfile index 83cc2cbe..ef4dcdf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,11 @@ RUN dotnet tool restore COPY .paket .paket COPY paket.references paket.references COPY paket.dependencies paket.lock ./ +# Each library's own Directory.Build.props imports this root file (via +# GetPathOfFileAbove) to share the single curated . Without it +# present at /workspace, that Import resolves to an empty path and MSBuild +# fails with MSB4020. +COPY Directory.Build.props . FROM build AS app-build @@ -34,6 +39,14 @@ RUN dotnet run bundle FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Curated single version number for the whole app (server, client, libraries). +# Sourced from the root Directory.Build.props by the caller (see docker-local.sh / +# docker-amd64.sh templates in DEVELOPMENT.md) so the image label always matches +# what was actually built, without duplicating the version here. +ARG APP_VERSION=0.0.0 +LABEL org.opencontainers.image.version="${APP_VERSION}" + COPY --from=app-build /workspace/deploy /app ENV GENPRES_LOG=0 From 08b0838714a4476e4f41d1b2f7f11fe0d8531a73 Mon Sep 17 00:00:00 2001 From: Dave Thomas <7sharp9@mail.com> Date: Mon, 3 Aug 2026 18:01:46 +0100 Subject: [PATCH 2/4] fix(docker): avoid leaking DockerRun secrets Update the DockerRun FAKE target to validate GENPRES_URL_ID and GENPRES_PASSWORD without embedding their values in docker CLI args. It now uses `-e VAR_NAME` passthrough instead of `-e VAR_NAME=value`, preventing secret exposure in process error output while still failing fast when variables are missing. --- Build.fs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Build.fs b/Build.fs index b1e24648..148b6c85 100644 --- a/Build.fs +++ b/Build.fs @@ -328,8 +328,12 @@ Target.create Target.create "DockerRun" (fun _ -> - let urlId = requireEnvVar "GENPRES_URL_ID" - let password = requireEnvVar "GENPRES_PASSWORD" + // Fail fast with a clear message, but don't pass the values as `-e NAME=value` args: `createProcess` + // (Helpers.fs) renders the full argument list into its failure message on any non-zero docker exit, + // which would leak GENPRES_URL_ID/GENPRES_PASSWORD as plain text. `-e NAME` (no `=value`) makes docker + // forward the variable from its own environment instead, so the secrets never appear in the args. + requireEnvVar "GENPRES_URL_ID" |> ignore + requireEnvVar "GENPRES_PASSWORD" |> ignore run docker @@ -340,9 +344,9 @@ Target.create "-p" "8080:8085" "-e" - $"GENPRES_URL_ID={urlId}" + "GENPRES_URL_ID" "-e" - $"GENPRES_PASSWORD={password}" + "GENPRES_PASSWORD" dockerImage ] "." From bfaf38efd3970467a939f7c9fe6ab00c2df9481e Mon Sep 17 00:00:00 2001 From: Dave Thomas <7sharp9@mail.com> Date: Mon, 3 Aug 2026 18:05:46 +0100 Subject: [PATCH 3/4] docs(docker): update Dockerfile version note Update the Dockerfile comment to reference the `DockerBuild` FAKE target in `Build.fs` and `DEVELOPMENT.md` as the source of `APP_VERSION`. This keeps the documentation aligned with the current Docker build workflow. --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ef4dcdf1..4216d0cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,9 +41,9 @@ RUN dotnet run bundle FROM mcr.microsoft.com/dotnet/aspnet:10.0 # Curated single version number for the whole app (server, client, libraries). -# Sourced from the root Directory.Build.props by the caller (see docker-local.sh / -# docker-amd64.sh templates in DEVELOPMENT.md) so the image label always matches -# what was actually built, without duplicating the version here. +# Sourced from the root Directory.Build.props by the caller (see the +# `DockerBuild` FAKE target in Build.fs / DEVELOPMENT.md) so the image label +# always matches what was actually built, without duplicating the version here. ARG APP_VERSION=0.0.0 LABEL org.opencontainers.image.version="${APP_VERSION}" From 9f93c89b595f6d5da96130799d8459eaaf67a549 Mon Sep 17 00:00:00 2001 From: Dave Thomas <7sharp9@mail.com> Date: Mon, 3 Aug 2026 18:33:08 +0100 Subject: [PATCH 4/4] build(config): reject whitespace Docker env vars Update `requireEnvVar` in `Build.fs` to treat whitespace-only values as missing, so `DockerRun` fails fast with a clear error instead of accepting invalid env values. Also update Docker documentation in `AGENTS.md`, `.github/copilot-instructions.md`, and `DEVELOPMENT.md` to clarify that the image defaults to `GENPRES_PROD=1`, so both `GENPRES_URL_ID` and a 16+ character `GENPRES_PASSWORD` are required at runtime. --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- Build.fs | 4 ++-- DEVELOPMENT.md | 20 ++++---------------- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 35bfc87d..199a1259 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,7 +78,7 @@ dotnet test tests/Informedica.GenUNITS.Tests/ ### Docker -`GENPRES_URL_ID` is required at server startup, in both demo and production mode — not just for admin operations. The proprietary production value is **not** baked into the image; inject it (and `GENPRES_PASSWORD` for admin operations) at container runtime, ideally via a Docker / Kubernetes secret. For local testing without production credentials, use the public demo sheet ID documented in `.env.example`. +`GENPRES_URL_ID` is required at server startup, in both demo and production mode — not just for admin operations. The image also sets `GENPRES_PROD=1` by default, so the server refuses to start unless `GENPRES_PASSWORD` is also set to at least 16 characters — it's not purely an admin-operations toggle either. Neither the proprietary URL ID nor the password is baked into the image; inject both at container runtime, ideally via a Docker / Kubernetes secret. For local testing without production credentials, use the public demo sheet ID documented in `.env.example`. - `dotnet run DockerBuild` - Build the image, labelled with the version from the root `Directory.Build.props`. Override the image name with `DOCKER_IMAGE` (default `halcwb/genpres`), cross-build a different platform with `DOCKER_PLATFORM`. - `dotnet run DockerRun` - Run the built image, reading `GENPRES_URL_ID`/`GENPRES_PASSWORD` from the current environment (source `.env` first) and failing fast if either is unset. diff --git a/AGENTS.md b/AGENTS.md index 1550b993..9faba541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ dotnet test tests/Informedica.GenUNITS.Tests/ ### Docker -`GENPRES_URL_ID` is required at server startup, in both demo and production mode — not just for admin operations. The proprietary production value is **not** baked into the image; inject it (and `GENPRES_PASSWORD` for admin operations) at container runtime, ideally via a Docker / Kubernetes secret. For local testing without production credentials, use the public demo sheet ID documented in `.env.example`. +`GENPRES_URL_ID` is required at server startup, in both demo and production mode — not just for admin operations. The image also sets `GENPRES_PROD=1` by default, so the server refuses to start unless `GENPRES_PASSWORD` is also set to at least 16 characters — it's not purely an admin-operations toggle either. Neither the proprietary URL ID nor the password is baked into the image; inject both at container runtime, ideally via a Docker / Kubernetes secret. For local testing without production credentials, use the public demo sheet ID documented in `.env.example`. - `dotnet run DockerBuild` - Build the image, labelled with the version from the root `Directory.Build.props`. Override the image name with `DOCKER_IMAGE` (default `halcwb/genpres`), cross-build a different platform with `DOCKER_PLATFORM`. - `dotnet run DockerRun` - Run the built image, reading `GENPRES_URL_ID`/`GENPRES_PASSWORD` from the current environment (source `.env` first) and failing fast if either is unset. diff --git a/Build.fs b/Build.fs index 148b6c85..967715f0 100644 --- a/Build.fs +++ b/Build.fs @@ -279,8 +279,8 @@ Target.create let requireEnvVar name = match System.Environment.GetEnvironmentVariable name with - | null - | "" -> failwithf "%s is not set. Load it from .env first (see DEVELOPMENT.md)." name + | v when System.String.IsNullOrWhiteSpace v -> + failwithf "%s is not set. Load it from .env first (see DEVELOPMENT.md)." name | v -> v diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e678eef3..37b945dd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -238,18 +238,9 @@ dotnet run ##### Docker wrappers -Building and running the image no longer needs a hand-copied shell script: the `DockerBuild` -and `DockerRun` FAKE targets (see [FAKE Build Targets Reference](#fake-build-targets-reference)) -cover both, work identically from PowerShell, Git Bash, or any POSIX shell, and are tracked in -`Build.fs` rather than living only as documentation. Neither target bakes `GENPRES_URL_ID` into -the image — that constraint is enforced by the `Dockerfile` itself and described in - [Environment Configuration](#environment-configuration). - -**Build** — `dotnet run DockerBuild` reads the app's single curated version number from the root -`Directory.Build.props` and passes it to `docker build --build-arg APP_VERSION=...`, so the image's -`org.opencontainers.image.version` label always matches what was built. To cross-build for -a different platform set `DOCKER_PLATFORM`; to tag/push under your own name instead of the -project's `halcwb/genpres` default, set `DOCKER_IMAGE` (both `DockerBuild` and `DockerRun` read it). +Building and running the image no longer needs a hand-copied shell script: the `DockerBuild` and `DockerRun` FAKE targets (see [FAKE Build Targets Reference](#fake-build-targets-reference)) cover both, work identically from PowerShell, Git Bash, or any POSIX shell, and are tracked in `Build.fs` rather than living only as documentation. Neither target bakes `GENPRES_URL_ID` into the image — that constraint is enforced by the `Dockerfile` itself and described in [Environment Configuration](#environment-configuration). + +**Build** — `dotnet run DockerBuild` reads the app's single curated version number from the root `Directory.Build.props` and passes it to `docker build --build-arg APP_VERSION=...`, so the image's `org.opencontainers.image.version` label always matches what was built. To cross-build for a different platform set `DOCKER_PLATFORM`; to tag/push under your own name instead of the project's `halcwb/genpres` default, set `DOCKER_IMAGE` (both `DockerBuild` and `DockerRun` read it). ```bash # local architecture @@ -265,10 +256,7 @@ $env:DOCKER_PLATFORM = "linux/amd64" dotnet run DockerBuild ``` -**Run** — `dotnet run DockerRun` reads `GENPRES_URL_ID` and `GENPRES_PASSWORD` from the current -environment and fails fast with an error if either is missing, rather than starting an -unauthenticated container that the in-server `validateProductionPassword` would refuse later. -Source `.env` first (single source of truth — same as `prod.sh` / `debug.sh`): +**Run** — `dotnet run DockerRun` reads `GENPRES_URL_ID` and `GENPRES_PASSWORD` from the current environment and fails fast with an error if either is missing, rather than starting an unauthenticated container that the in-server `validateProductionPassword` would refuse later. Source `.env` first (single source of truth — same as `prod.sh` / `debug.sh`): ```bash set -a; source .env; set +a