diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..334ae61e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Keep the Darling image build context lean (#1804): the Dockerfile copies source and publishes +# inside the build stage, so nothing prebuilt, versioned-binary, or platform-foreign belongs in it. +.git +.github +**/bin +**/obj +**/publish +releases +Darling/artifacts +deprecated +docs +*.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0951a4d1..8a725672 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -728,3 +728,64 @@ jobs: ${{ runner.temp }}/darling-pg.log TestResults/ if-no-files-found: ignore + + # ── Linux service build + container image (#1804) ──────────────────────────────────────────────── + # The Darling service is cross-platform .NET on purpose, but until this job nothing PROVED it on + # every PR — the linux-x64 publish and the container image both built for the first time at release + # time or never. Same path-filter shape as darling-pg above: only runs the expensive work when + # Darling/service code (or this workflow, or the Dockerfile) changed, always reports a result so it + # can be a required check. No tests run here — the test projects are net10.0-windows (they reference + # the WPF apps); the cross-platform behavior they pin is exercised by the Windows jobs, and the + # container smoke lives in the compose quickstart. This job answers exactly two questions: does the + # service still publish for linux-x64, and does the image still build. + darling-linux: + name: Darling Linux build + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - uses: actions/checkout@v7 + + - name: Detect changed paths + id: filter + if: github.event_name != 'release' + uses: dorny/paths-filter@v4 + with: + base: ${{ github.event_name == 'push' && github.event.before || '' }} + filters: | + darling: + - 'Darling/**/!(*.md)' + - 'PerformanceMonitor.Common/**' + - 'PerformanceMonitor.Collectors/**' + - 'PerformanceMonitor.Analysis/**' + - '.github/workflows/build.yml' + + - name: Report the Linux gate decision + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "release" ]; then + echo "::notice title=Darling Linux build skipped::Release event - the dev push that produced this commit already ran it." + elif [ "${{ steps.filter.outputs.darling }}" = "true" ]; then + echo "::notice title=Darling Linux build running::Darling/service code (or this workflow) changed." + else + echo "::notice title=Darling Linux build skipped::No Darling/service code changed." + fi + + - name: Setup .NET 10.0 + if: steps.filter.outputs.darling == 'true' + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Publish service (linux-x64) + if: steps.filter.outputs.darling == 'true' + run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux + + - name: Build container image + if: steps.filter.outputs.darling == 'true' + run: docker build -f Darling/Dockerfile -t performancemonitor-darling:pr . diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 04bbea9d..7f9e6dde 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -14,6 +14,8 @@ on: permissions: contents: write + # #1804: the linux job pushes the nightly container image to ghcr. + packages: write jobs: # Scheduled workflows always execute the DEFAULT branch's copy of this file, while nightly @@ -389,3 +391,66 @@ jobs: ${{ runner.temp }}/darling-pg.log TestResults/ if-no-files-found: ignore + + # ── Linux artifact + container image (#1804) ───────────────────────────────────────────────────── + # Runs AFTER the windows build job so the nightly release exists to upload into. Publishes the + # linux-x64 service tar.gz with its own checksum file (the windows job owns SHA256SUMS.txt; a + # cross-job rewrite of one file is a race), and pushes the service image to ghcr tagged :nightly. + # The bundled pg-runtime is deliberately absent from the linux artifact — the compose distribution + # pairs the service with the official timescale/timescaledb image, and managed mode stays Windows. + linux: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + with: + ref: dev + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Set nightly version + id: version + shell: bash + run: | + set -euo pipefail + base=$(grep -oPm1 '(?<=)[^<]+' Lite/PerformanceMonitorLite.csproj) + date=$(date +%Y%m%d) + echo "VERSION=${base}-nightly.${date}" >> "$GITHUB_OUTPUT" + echo "Nightly version: ${base}-nightly.${date}" + + - name: Publish service (linux-x64) + run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux + + - name: Package linux artifact + checksum + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.VERSION }}" + mkdir -p releases + tar -C publish/DarlingService-linux -czf "releases/PerformanceMonitorDarling-linux-x64-${version}.tar.gz" . + (cd releases && sha256sum "PerformanceMonitorDarling-linux-x64-${version}.tar.gz" > SHA256SUMS-linux.txt && cat SHA256SUMS-linux.txt) + + - name: Upload linux artifact to the nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload nightly releases/PerformanceMonitorDarling-linux-x64-*.tar.gz releases/SHA256SUMS-linux.txt --clobber + + - name: Build and push container image (ghcr, :nightly) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.VERSION }}" + image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" + echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + docker build -f Darling/Dockerfile -t "${image}:nightly" -t "${image}:${version}" . + docker push "${image}:nightly" + docker push "${image}:${version}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 90468989..40ceecec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The Darling service builds for Linux on every PR and ships a nightly container image** ([#1804] stage 3) - the collector service is cross-platform .NET on purpose, but nothing PROVED it: the linux-x64 publish and the container image built at release time or never. A checked-in \`Darling/Dockerfile\` builds from source (sdk stage publish into the official aspnet runtime image - which sets the \`DOTNET_RUNNING_IN_CONTAINER\` marker the stage-2 bind gate keys on, and gains \`libgssapi-krb5-2\`, which Microsoft.Data.SqlClient probes at connect time on Linux even for SQL auth: found the hard way when the container smoke's first SQL connect failed on the missing library). CI gains a path-filtered \`darling-linux\` job answering exactly two questions on every Darling PR - does the service still publish for linux-x64, and does the image still build - and the nightly gains a linux job that uploads \`PerformanceMonitorDarling-linux-x64-*.tar.gz\` (its own checksum file; the Windows job owns SHA256SUMS.txt) and pushes \`ghcr.io/erikdarlingdata/performancemonitor-darling:nightly\`. The whole path was validated end-to-end locally first: the containerized service against dockerized TimescaleDB and SQL Server 2022 ran 25 collectors green with an \`env:\`-referenced SQL password, the web login/token/cookie gate and the MCP bearer gate answering correctly through mapped ports - a run that also caught a config-validation warning still claiming the network block is IGNORED in containers while the host correctly exposed it (fixed), and proved the store-authoritative control plane overrides the file on a reused store exactly as documented. The bundled pg-runtime is deliberately absent from the linux artifact: compose pairs the service with the official timescale/timescaledb image, and managed mode stays Windows. + - **Network exposure works in a container: the bind ladder's managed-mode gate extends to \`managed OR containerized\`** ([#1804] stage 2) - under \`postgres.managed = false\` the shared bind ladder refuses web/MCP network exposure and degrades to loopback-only ('your own reverse proxy governs BYO exposure') - the right rule on a host, and dead-on-arrival inside a container, where compose port mapping cannot reach a loopback bind and the mapping itself IS the boundary the reverse-proxy rule was standing in for. The pure decision table gains one input: \`inContainer\` (the official .NET images' \`DOTNET_RUNNING_IN_CONTAINER\` marker, read by the callers and passed in so the ladder stays pure), and the managed requirement relaxes for it - NOTHING ELSE does: the bearer token and the valid same-family \`allowFrom\` CIDR are required identically, a containerized not-exposed block skips the now-false 'network.* is ignored' notice, and the uncontained BYO rule is byte-for-byte unchanged, all pinned by new decision-table tests. - **Secrets without DPAPI: every plaintext secret slot also takes an \`env:NAME\` or \`file:/path\` reference** ([#1804] stage 1 - the largest piece of the Linux/compose distribution, and useful to Windows BYO-store shops today) - the DPAPI fields (\`encryptedPassword\`, \`encryptedToken\`) are Windows-only by nature, and the only alternative was a literal secret sitting in \`darling.json\`, warned on every use. A monitored server's \`password\`, the new \`smtp.password\` (before this, SMTP had ONLY the DPAPI field - non-Windows hosts had no email-alerting path at all), and the mcp/web \`network.token\` slots now all accept a reference: \`env:\` reads the named environment variable, \`file:\` reads the file's trimmed contents (compose \`secrets:\` mounts end with a newline, and a newline inside a password is never what the operator meant). A reference is not a secret in the config file, which is the whole point - it does not trip the plaintext warnings - and a missing or empty target is a configuration error naming both the setting and the target, never a silent empty secret. The prefixes match case-sensitively and only at the start, so an exotic literal is expressible via a file. DPAPI stays preferred and byte-for-byte unchanged on Windows. diff --git a/Darling/Darling.Tests/DarlingWorkerTests.cs b/Darling/Darling.Tests/DarlingWorkerTests.cs index 66be36bc..04248955 100644 --- a/Darling/Darling.Tests/DarlingWorkerTests.cs +++ b/Darling/Darling.Tests/DarlingWorkerTests.cs @@ -175,7 +175,11 @@ public void GetNetworkStartupWarnings_ByoWithNetwork_WarnsBothSectionsIgnored() var warnings = DarlingWorker.GetNetworkStartupWarnings(config); Assert.Contains(warnings, w => w.Contains("postgres.network", StringComparison.Ordinal) && w.Contains("bring-your-own", StringComparison.Ordinal)); - Assert.Contains(warnings, w => w.Contains("mcp.network", StringComparison.Ordinal) && w.Contains("managed-mode only", StringComparison.Ordinal)); + /* #1804: the mcp notice names the container path now — in a container the block is HONORED, so + the notice is suppressed there (see the bind ladder's container gate); outside one (this test + process) it still fires. The postgres.network notice is container-independent: the bundled + store never runs in BYO mode. */ + Assert.Contains(warnings, w => w.Contains("mcp.network", StringComparison.Ordinal) && w.Contains("managed-mode (or container, #1804) only", StringComparison.Ordinal)); /* BYO never emits the admin-pivot warning — the network config is ignored anyway. */ Assert.DoesNotContain(warnings, w => w.Contains("pivot", StringComparison.OrdinalIgnoreCase) || w.Contains("config_command", StringComparison.Ordinal)); } diff --git a/Darling/Dockerfile b/Darling/Dockerfile new file mode 100644 index 00000000..7dfb9803 --- /dev/null +++ b/Darling/Dockerfile @@ -0,0 +1,37 @@ +# The Darling service container (#1804) — builds from source so `docker build` works from any checkout: +# +# docker build -f Darling/Dockerfile -t performancemonitor-darling . +# +# Build context is the REPO ROOT (the service references PerformanceMonitor.Common / .Collectors / +# .Analysis / Darling.Storage by project). The runtime stage is the official aspnet image (the service +# hosts the web dashboard + MCP endpoint on Kestrel) and sets DOTNET_RUNNING_IN_CONTAINER, which is what +# the bind ladder's container gate keys on — network exposure with the mandatory token/CIDR works here +# under postgres.managed = false, because the compose port mapping is the boundary. +# +# The bundled zero-admin store is Windows-only BY DESIGN and never runs in this image: pair the container +# with the official timescale/timescaledb image (see the compose file) and keep postgres.managed = false. +# Secrets never land in darling.json — use env:/file: references (#1804 stage 1), which are compose +# `secrets:`-friendly. + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj \ + -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +# Microsoft.Data.SqlClient probes the Kerberos GSSAPI library at connect time on Linux — even for SQL +# auth — and the aspnet base image does not carry it. Found the hard way in the #1804 container smoke. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgssapi-krb5-2 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /app/publish . + +# The config mount point the compose file uses; override DARLING_CONFIG to relocate. +ENV DARLING_CONFIG=/config/darling.json + +# Web dashboard + MCP (both opt-in via darling.json; exposed here only behind their token gates). +EXPOSE 5153 5152 + +ENTRYPOINT ["dotnet", "PerformanceMonitor.Darling.Service.dll"] diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index 294492b8..4a01b928 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -232,10 +232,14 @@ internal static IReadOnlyList GetNetworkStartupWarnings(DarlingConfig co "postgres.network.* is set but postgres.managed is false — it is IGNORED in bring-your-own mode; your own PostgreSQL governs its network exposure (pg_hba / listen_addresses / TLS)."); } - if (config.Mcp.Network?.IsConfigured == true) + /* #1804: in a container the mcp/web network blocks ARE honored (the bind ladder's container + gate), so this notice would be a lie there — the smoke test caught it warning IGNORED in + the same breath as 'Starting MCP server on 0.0.0.0'. The postgres.network notice above + stays: the bundled store never runs in BYO mode, container or not. */ + if (config.Mcp.Network?.IsConfigured == true && !Hosting.DarlingHostBinding.IsRunningInContainer) { warnings.Add( - "mcp.network.* is set but postgres.managed is false — the MCP network endpoint is managed-mode only, so it is IGNORED; the MCP server stays loopback-only."); + "mcp.network.* is set but postgres.managed is false — the MCP network endpoint is managed-mode (or container, #1804) only, so it is IGNORED; the MCP server stays loopback-only."); } return warnings;